commit5
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-10-30 10:55:18 +02:00
parent 0235c7609e
commit 48347e2d3a
161 changed files with 11814 additions and 265 deletions
+11
View File
@@ -0,0 +1,11 @@
<?php
require_once __DIR__.'/src/Payables.php';
require_once __DIR__.'/src/Workspace.php';
require_once __DIR__.'/src/PayablesSchema.php';
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';
@@ -0,0 +1,113 @@
<?php
namespace Tempo\Finance\Payables;
use Illuminate\Database\Connection;
use Illuminate\Database\ConnectionInterface;
/** Imports existing facts only; never executes or confirms a payment. */
final class HistoricalPayments
{
public const VERSION = 'PAYABLE-HISTORY-IMPORT-v1';
public function __construct(private ConnectionInterface $db, private array $scope) {}
public function available(): bool
{
return $this->db instanceof Connection
&& $this->db->getSchemaBuilder()->hasTable('pa_history_imports')
&& $this->db->getSchemaBuilder()->hasTable('pa_imported_payments');
}
public static function capture(array $source, array $payments): array
{
if (! is_int($source['payment_count'] ?? null) || $source['payment_count'] < 1 || count($payments) !== $source['payment_count']) {
throw new \DomainException('Histórico completo de pagamentos obrigatório; sem omissões ou importação vazia.');
}
$rows = [];
$totals = ['confirmed' => 0, 'pending' => 0, 'rejected' => 0];
foreach ($payments as $payment) {
foreach (['id', 'company', 'document', 'amount_minor', 'created_by', 'registration_audit_id'] as $key) {
if (! is_int($payment[$key] ?? null) || $payment[$key] < 1) {
throw new \DomainException('Referência ou montante de pagamento inválido: '.$key);
}
}
$id = $payment['id'];
$status = $payment['status'] ?? '';
if (isset($rows[$id]) || $payment['company'] !== $source['company'] || $payment['document'] !== $source['document']
|| ($payment['currency'] ?? null) !== ($source['currency'] ?? null) || ! isset($totals[$status])) {
throw new \DomainException('Pagamento duplicado ou incompatível com empresa, factura, moeda ou estado.');
}
$date = $payment['paid_on'] ?? '';
if (! is_string($date) || ! preg_match('/^\d{4}-\d{2}-\d{2}$/D', $date)
|| ! checkdate((int) substr($date, 5, 2), (int) substr($date, 8, 2), (int) substr($date, 0, 4))) {
throw new \DomainException('Data original do pagamento inválida.');
}
if ($status !== 'pending' && (! is_int($payment['reviewed_by'] ?? null) || $payment['reviewed_by'] < 1
|| $payment['reviewed_by'] === $payment['created_by'] || ! is_int($payment['decision_audit_id'] ?? null)
|| $payment['decision_audit_id'] < 1 || empty($payment['reviewed_at']))) {
throw new \DomainException('Confirmação ou rejeição sem decisão independente e auditada.');
}
if ($status === 'pending' && (($payment['reviewed_by'] ?? null) !== null || ($payment['decision_audit_id'] ?? null) !== null || ($payment['reviewed_at'] ?? null) !== null)) {
throw new \DomainException('Pagamento pendente com decisão incompatível.');
}
if ($totals[$status] > PHP_INT_MAX - $payment['amount_minor']) {
throw new \DomainException('Total histórico fora dos limites suportados.');
}
$totals[$status] += $payment['amount_minor'];
$rows[$id] = ['id' => $id, 'company' => $payment['company'], 'document' => $payment['document'],
'amount_minor' => $payment['amount_minor'], 'currency' => $payment['currency'], 'status' => $status,
'created_by' => $payment['created_by'], 'paid_on' => $date, 'registration_audit_id' => $payment['registration_audit_id'],
'reviewed_by' => $payment['reviewed_by'] ?? null, 'reviewed_at' => $payment['reviewed_at'] ?? null,
'decision_audit_id' => $payment['decision_audit_id'] ?? null,
'payment_key' => hash('sha256', Payables::canonical([$source['instance'], $source['company'], $id]))];
}
if (! is_int($source['total_minor'] ?? null) || $totals['confirmed'] > $source['total_minor']) {
throw new \DomainException('Pagamentos confirmados superiores ao principal documental.');
}
ksort($rows);
$capture = ['version' => self::VERSION, 'source_key' => Payables::identity($source), 'payments' => array_values($rows),
'confirmed_minor' => $totals['confirmed'], 'pending_minor' => $totals['pending'], 'rejected_minor' => $totals['rejected']];
return $capture + ['history_hash' => hash('sha256', Payables::canonical($capture))];
}
public function imported(int $obligation): ?object
{
return $this->available() ? $this->db->table('pa_history_imports')->where($this->scope)->where('obligation_id', $obligation)->first() : null;
}
public function record(int $obligation, int $source, int $actor, array $capture): void
{
if (! $this->available() || $this->db->transactionLevel() === 0) {
throw new \LogicException('Esquema e transacção de importação obrigatórios.');
}
$this->db->table('pa_history_imports')->insert($this->scope + ['obligation_id' => $obligation, 'source_id' => $source,
'source_key' => $capture['source_key'], 'history_hash' => $capture['history_hash'],
'confirmed_minor' => $capture['confirmed_minor'], 'pending_minor' => $capture['pending_minor'], 'rejected_minor' => $capture['rejected_minor'],
'snapshot' => Payables::canonical($capture), 'imported_by' => $actor, 'created_at' => date('Y-m-d H:i:s')]);
foreach ($capture['payments'] as $payment) {
$this->db->table('pa_imported_payments')->insert($this->scope + ['obligation_id' => $obligation, 'source_id' => $source,
'payment_key' => $payment['payment_key'], 'legacy_payment_id' => $payment['id'], 'amount_minor' => $payment['amount_minor'],
'status' => $payment['status'], 'snapshot' => Payables::canonical($payment), 'created_at' => date('Y-m-d H:i:s')]);
}
}
public function matches(object $import, array $capture): bool
{
$rows = $this->db->table('pa_imported_payments')->where($this->scope)->where('obligation_id', $import->obligation_id)->orderBy('legacy_payment_id')->get();
if ($import->history_hash !== $capture['history_hash'] || $import->source_key !== $capture['source_key']
|| Payables::canonical(json_decode($import->snapshot, true, 64, JSON_THROW_ON_ERROR)) !== Payables::canonical($capture) || $rows->count() !== count($capture['payments'])) {
return false;
}
foreach ($rows as $index => $row) {
$payment = $capture['payments'][$index];
if ($row->payment_key !== $payment['payment_key'] || (int) $row->amount_minor !== $payment['amount_minor']
|| $row->status !== $payment['status'] || Payables::canonical(json_decode($row->snapshot, true, 64, JSON_THROW_ON_ERROR)) !== Payables::canonical($payment)) {
return false;
}
}
return (int) $import->confirmed_minor === $capture['confirmed_minor'] && (int) $import->pending_minor === $capture['pending_minor'] && (int) $import->rejected_minor === $capture['rejected_minor'];
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace Tempo\Finance\Payables;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class HistorySchema
{
public static function install(): void
{
$driver = DB::connection()->getDriverName();
if (! in_array($driver, ['pgsql', 'sqlite'], true) || ! Schema::hasTable('pa_obligations')
|| Schema::hasTable('pa_history_imports') || Schema::hasTable('pa_imported_payments')) {
throw new \LogicException('Esquema anterior obrigatório; não adoptar nem reparar tabelas históricas implicitamente.');
}
$scope = function (Blueprint $table) {
foreach (['tenant_id', 'company_id', 'unit_id'] as $key) {
$table->unsignedBigInteger($key);
}
};
Schema::create('pa_history_imports', function (Blueprint $table) use ($scope) {
$table->id();
$scope($table);
$table->foreignId('obligation_id')->unique()->constrained('pa_obligations');
$table->foreignId('source_id')->unique()->constrained('pa_sources');
$table->string('source_key', 64);
$table->string('history_hash', 64);
$table->bigInteger('confirmed_minor');
$table->bigInteger('pending_minor');
$table->bigInteger('rejected_minor');
$table->jsonb('snapshot');
$table->unsignedBigInteger('imported_by');
$table->timestamp('created_at');
});
Schema::create('pa_imported_payments', function (Blueprint $table) use ($scope) {
$table->id();
$scope($table);
$table->foreignId('obligation_id')->constrained('pa_obligations');
$table->foreignId('source_id')->constrained('pa_sources');
$table->string('payment_key', 64)->unique();
$table->unsignedBigInteger('legacy_payment_id');
$table->bigInteger('amount_minor');
$table->string('status');
$table->jsonb('snapshot');
$table->timestamp('created_at');
$table->unique(['source_id', 'legacy_payment_id']);
});
foreach (['pa_history_imports', 'pa_imported_payments'] as $table) {
if ($driver === 'pgsql') {
DB::unprepared("CREATE TRIGGER pa_history_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION pa_append_only()");
} else {
foreach (['UPDATE', 'DELETE'] as $operation) {
$trigger = $table.'_immutable_'.strtolower($operation);
DB::unprepared("CREATE TRIGGER {$trigger} BEFORE {$operation} ON {$table} BEGIN SELECT RAISE(ABORT, 'PA_APPEND_ONLY'); END");
}
}
}
}
}
+368
View File
@@ -0,0 +1,368 @@
<?php
namespace Tempo\Finance\Payables;
use Illuminate\Database\ConnectionInterface;
/** One payable per document identity. No session, money rounding or budget effects. */
final class Payables
{
public const VERSION = 'COMMON-PAYABLE-SOURCE-SIM-v1';
public function __construct(private ConnectionInterface $db, private int $actor, private string $person, private array $scope,
private \Closure $authorize, private \Closure $loadSource, private ?\Closure $loadHistory = null) {}
public static function canonical(array $data): string
{
$sort = function ($v) use (&$sort) {
if (! is_array($v)) {
return $v;
} if (! array_is_list($v)) {
ksort($v);
}
return array_map($sort, $v);
};
return json_encode($sort($data), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
public static function identity(array $s): string
{
return hash('sha256', self::canonical(array_intersect_key($s, array_flip(['instance', 'company', 'document']))));
}
public static function revision(array $s): string
{
unset($s['payment_count'],$s['local_id']);
return hash('sha256', self::canonical($s));
}
public static function missing(array $s): array
{
$m = [];
foreach (['instance', 'company', 'document', 'number', 'supplier_id', 'supplier_name', 'order_id', 'validated_by', 'validated_at', 'validation_audit_id'] as $k) {
if (empty($s[$k])) {
$m[] = 'Origem/decisão em falta: '.$k;
}
}
if (($s['status'] ?? null) !== 'validated') {
$m[] = 'Factura ainda não conferida.';
}
if (($s['currency'] ?? null) !== 'MZN') {
$m[] = 'Moeda ausente ou incompatível; apenas MZN neste recorte.';
}
foreach (['subtotal_minor', 'tax_minor', 'total_minor'] as $k) {
if (! isset($s[$k]) || ! is_int($s[$k]) || $s[$k] < 0) {
$m[] = 'Montante sustentado em falta: '.$k;
}
}
if (isset($s['subtotal_minor'],$s['tax_minor'],$s['total_minor']) && $s['total_minor'] !== $s['subtotal_minor'] + $s['tax_minor']) {
$m[] = 'Componentes documentais incompatíveis; sem recálculo.';
}
if (empty($s['lines'])) {
$m[] = 'Linhas conferidas em falta.';
}
if (! array_key_exists('payment_count', $s) || $s['payment_count'] !== 0) {
$m[] = 'Histórico de pagamentos existente ou desconhecido; exige passagem de responsabilidade própria.';
}
return $m;
}
private function q(string $table)
{
return $this->db->table('pa_'.$table)->where($this->scope);
}
private function event(string $kind, int $source, array $data): void
{
$id = $this->db->table('pa_events')->insertGetId($this->scope + ['source_id' => $source, 'actor_id' => $this->actor, 'kind' => $kind, 'payload' => self::canonical($data), 'created_at' => date('Y-m-d H:i:s')]);
$this->db->table('pa_outbox')->insert(['event_id' => $id, 'state' => 'captured_no_settlement_or_accounting']);
}
private function source(int $local): array
{
$s = ($this->loadSource)($local);
foreach (['instance', 'company', 'document'] as $key) {
if (empty($s[$key])) {
throw new \DomainException('Identidade de origem insuficiente.');
}
}
return $s;
}
private function locked(array $s): object
{
$key = self::identity($s);
$this->db->table('pa_sources')->insertOrIgnore($this->scope + ['source_key' => $key, 'local_source_id' => $s['local_id'], 'created_at' => date('Y-m-d H:i:s')]);
$row = $this->db->table('pa_sources')->where('source_key', $key)->lockForUpdate()->first();
if (! $row) {
throw new \DomainException('Identidade documental comercial já associada; revisão necessária.');
}
foreach ($this->scope as $k => $v) {
if ((int) $row->$k !== $v) {
throw new \DomainException('Origem já associada a outro contexto.');
}
}
if ((int) $row->local_source_id !== (int) $s['local_id']) {
throw new \DomainException('Origem canónica já ligada a outro documento local.');
}
$this->singleResponsibility($row, (int) $s['local_id']);
return $row;
}
private function singleResponsibility(object $source, int $local): void
{
$otherSources = $this->q('sources')->where('local_source_id', $local)->where('id', '<>', $source->id)->pluck('id')->all();
if ($this->q('obligations')->whereIn('source_id', $otherSources)->exists()) {
throw new \DomainException('Documento local já incorporado com outra identidade; reconciliar sem segunda obrigação.');
}
}
private function history(array $source): array
{
if ($this->loadHistory === null || ! (new HistoricalPayments($this->db, $this->scope))->available()) {
throw new \DomainException('Passagem de histórico não activada nesta instalação.');
}
return HistoricalPayments::capture($source, ($this->loadHistory)($source['local_id']));
}
private function eligibility(array $source, bool $historical): array
{
if ($historical) {
$source['payment_count'] = 0;
}
return self::missing($source);
}
private function position(object $obligation, ?array $source): array
{
$store = new HistoricalPayments($this->db, $this->scope);
$import = $store->imported((int) $obligation->id);
$current = null;
$compatible = false;
if ($source) {
$compatible = ($source['revision'] ?? self::revision($source)) === $obligation->source_revision
&& $this->eligibility($source, $import !== null) === [];
if ($import) {
try {
$current = (new PaymentDecisions($this->db, $this->scope))->current($import);
$compatible = $compatible && self::canonical($current) === self::canonical($this->history($source));
} catch (\DomainException $e) {
$compatible = false;
}
}
}
$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 ? $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) {
($this->authorize)('view');
$source = $this->source($local);
$row = $this->q('sources')->where('source_key', self::identity($source))->first();
if (! $row) {
$ids = $this->q('sources')->where('local_source_id', $local)->pluck('id')->all();
if ($this->q('obligations')->whereIn('source_id', $ids)->exists()) {
throw new \DomainException('Identidade da instalação alterada; reconciliar origem antes de calcular saldo.');
}
return null;
}
$this->singleResponsibility($row, $local);
$obligation = $this->q('obligations')->where('source_id', $row->id)->first();
return $obligation ? $this->position($obligation, $source) : null;
});
}
public function decideImportedPayment(int $local, int $payment, \Closure $writeOriginal): void
{
$this->db->transaction(function () use ($local, $payment, $writeOriginal) {
($this->authorize)('validate');
$source = $this->source($local);
$row = $this->locked($source);
$obligation = $this->q('obligations')->where('source_id', $row->id)->first();
$import = $obligation ? (new HistoricalPayments($this->db, $this->scope))->imported((int) $obligation->id) : null;
if (! $import || $this->position($obligation, $source)['source_conflict']) {
throw new \DomainException('Obrigação histórica ausente ou divergente.');
}
$writeOriginal();
$after = $this->history($this->source($local));
(new PaymentDecisions($this->db, $this->scope))->record($import, $after, $payment, $this->actor);
$this->event('payable.imported_payment_decided', (int) $row->id, ['obligation_id' => $obligation->id, 'payment_id' => $payment, 'history_hash' => $after['history_hash']]);
});
}
public function command(string $action, int $id, string $reason, string $key): array
{
if (! in_array($action, ['prepare', 'validate', 'prepare_history', 'validate_history'], true) || ! preg_match('/^[a-f0-9-]{36}$/Di', $key) || mb_strlen(trim($reason)) < 3 || mb_strlen($reason) > 2000) {
throw new \InvalidArgumentException('Acção, chave ou fundamento inválidos.');
}
return $this->db->transaction(function () use ($action, $id, $reason, $key) {
$historical = str_ends_with($action, '_history');
$operation = $historical ? substr($action, 0, -8) : $action;
($this->authorize)($operation);
$hash = hash('sha256', self::canonical([$action, $id, $reason]));
$old = $this->q('commands')->where(['actor_id' => $this->actor, 'command_key' => $key])->first();
if ($old) {
if ($old->request_hash !== $hash) {
throw new \DomainException('Chave usada com conteúdo diferente.');
}
// Even replay rechecks the current source/revision/payment eligibility.
$result = json_decode($old->result, true, 32, JSON_THROW_ON_ERROR);
$local = $result['local_source_id'];
$s = $this->source($local);
$row = $this->locked($s);
$capture = $historical ? $this->history($s) : null;
$material = self::revision($s);
if (($result['source_revision'] ?? null) !== $material || $this->eligibility($s, $historical) !== ($result['missing'] ?? [])
|| ($result['history_hash'] ?? null) !== ($capture['history_hash'] ?? null)) {
return $this->conflict($row, $local, 'A origem/eligibilidade mudou após o comando; original preservado.');
}
return $result + ['replayed' => true];
}
if ($operation === 'prepare') {
$s = $this->source($id);
$row = $this->locked($s);
$capture = $historical ? $this->history($s) : null;
$revision = self::revision($s);
$missing = $this->eligibility($s, $historical);
$ob = $this->q('obligations')->where('source_id', $row->id)->first();
if ($ob) {
$position = $this->position($ob, $s);
$result = ! $position['source_conflict'] && $position['history_imported'] === $historical && $missing === [] ? ['state' => 'recognised', 'obligation_id' => $ob->id, 'local_source_id' => $id, 'source_revision' => $revision, 'missing' => []] : $this->conflict($row, $id, 'Documento/histórico alterado ou obrigação já incorporada por outro percurso; nenhuma dívida adicional.');
} else {
$candidate = $this->q('proposals')->where('source_id', $row->id)->orderByDesc('id')->first();
$oldCapture = $candidate ? (json_decode($candidate->snapshot, true)['_history_import'] ?? null) : null;
if (! $candidate || $candidate->source_revision !== $revision || json_decode($candidate->missing, true) !== $missing
|| ($oldCapture['history_hash'] ?? null) !== ($capture['history_hash'] ?? null)) {
$snapshot = $s + ($capture ? ['_history_import' => $capture] : []);
$pid = $this->db->table('pa_proposals')->insertGetId($this->scope + ['source_id' => $row->id, 'source_revision' => $revision, 'capture_version' => ($candidate ? (int) $candidate->capture_version : 0) + 1, 'snapshot' => self::canonical($snapshot), 'missing' => self::canonical($missing), 'prepared_by' => $this->actor, 'prepared_person' => $this->person, 'reason' => $reason, 'created_at' => date('Y-m-d H:i:s')]);
$this->event('payable.proposed', $row->id, ['proposal_id' => $pid, 'source_revision' => $revision, 'missing' => $missing]);
} else {
$pid = $candidate->id;
}
$result = ['state' => $missing === [] ? 'prepared' : 'pending_source', 'proposal_id' => $pid, 'local_source_id' => $id, 'source_revision' => $revision, 'missing' => $missing];
}
} else {
$p = $this->q('proposals')->where('id', $id)->first();
if (! $p) {
throw new \DomainException('Proposta fora do âmbito.');
}
if ((int) $p->prepared_by === $this->actor || $p->prepared_person === $this->person) {
throw new \DomainException('Validação exige pessoa distinta da preparação.');
}
$row = $this->q('sources')->where('id', $p->source_id)->lockForUpdate()->first();
$local = (int) $row->local_source_id;
$this->singleResponsibility($row, $local);
$s = $this->source($local);
$capture = $historical ? $this->history($s) : null;
$preparedCapture = json_decode($p->snapshot, true)['_history_import'] ?? null;
if (($preparedCapture !== null) !== $historical) {
throw new \DomainException('Usar o percurso de validação correspondente à proposta.');
}
$revision = self::revision($s);
$missing = $this->eligibility($s, $historical);
$latest = $this->q('proposals')->where('source_id', $row->id)->orderByDesc('id')->first();
$ob = $this->q('obligations')->where('source_id', $row->id)->first();
if ($revision !== $p->source_revision || $latest->id !== $p->id || $missing !== []
|| ($preparedCapture['history_hash'] ?? null) !== ($capture['history_hash'] ?? null)) {
$result = $this->conflict($row, $local, 'Revisão/decisão/pagamentos incompatíveis ou informação insuficiente.');
} elseif ($ob) {
$position = $this->position($ob, $s);
$result = ! $position['source_conflict'] && $position['history_imported'] === $historical ? ['state' => 'recognised', 'obligation_id' => $ob->id, 'local_source_id' => $local, 'source_revision' => $revision, 'missing' => []] : $this->conflict($row, $local, 'Responsabilidade existente incompatível; nenhuma segunda obrigação.');
} else {
$oid = $this->db->table('pa_obligations')->insertGetId($this->scope + ['source_id' => $row->id, 'proposal_id' => $p->id, 'source_revision' => $revision, 'principal_minor' => $s['total_minor'], 'currency' => 'MZN', 'due_on' => null, 'recognised_by' => $this->actor, 'recognised_person' => $this->person, 'reason' => $reason, 'created_at' => date('Y-m-d H:i:s')]);
if ($capture) {
(new HistoricalPayments($this->db, $this->scope))->record((int) $oid, (int) $row->id, $this->actor, $capture);
$this->event('payable.legacy_history_imported', $row->id, ['obligation_id' => $oid, 'history_hash' => $capture['history_hash'],
'confirmed_minor' => $capture['confirmed_minor'], 'pending_minor' => $capture['pending_minor'], 'rejected_minor' => $capture['rejected_minor']]);
}
$this->event('payable.recognised', $row->id, ['obligation_id' => $oid, 'proposal_id' => $p->id, 'source_revision' => $revision, 'principal_minor' => $s['total_minor'], 'due_on' => null]);
$result = ['state' => 'recognised', 'obligation_id' => $oid, 'local_source_id' => $local, 'source_revision' => $revision, 'missing' => []];
}
}
if ($capture && ($result['state'] ?? '') !== 'conflict') {
$result['history_hash'] = $capture['history_hash'];
}
$this->db->table('pa_commands')->insert($this->scope + ['actor_id' => $this->actor, 'command_key' => $key, 'request_hash' => $hash, 'result' => self::canonical($result), 'created_at' => date('Y-m-d H:i:s')]);
return $result + ['replayed' => false];
});
}
private function conflict(object $row, int $local, string $reason): array
{
$this->event('payable.source_conflict', $row->id, ['reason' => $reason, 'observed_revision' => self::revision($this->source($local))]);
return ['state' => 'conflict', 'local_source_id' => $local, 'reason' => $reason];
}
public function listing(array $localSources): array
{
return $this->db->transaction(function () use ($localSources) {
($this->authorize)('view');
$documents = [];
foreach ($localSources as $id) {
$s = $this->source($id);
$documents[] = $s + ['revision' => self::revision($s), 'missing' => self::missing($s)];
}
$current = [];
foreach ($documents as $doc) {
$current[self::identity($doc)] = $doc;
}
$obs = $this->q('obligations')->orderBy('id')->get()->map(function ($o) use ($current) {
$origin = $this->q('sources')->where('id', $o->source_id)->first();
$doc = $current[$origin->source_key] ?? null;
return $this->position($o, $doc);
})->all();
return ['synthetic_only' => true, 'documents' => $documents, 'obligations' => $obs, 'proposals' => $this->q('proposals')->orderBy('id')->get(), 'events' => $this->q('events')->orderBy('id')->get(), 'permissions' => ['prepare' => false, 'validate' => false], 'current_actor' => $this->actor];
});
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
namespace Tempo\Finance\Payables;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/** Shared additive schema; installation never grants access or imports documents. */
final class PayablesSchema
{
public static function install(): void
{
$driver = DB::connection()->getDriverName();
if (! in_array($driver, ['pgsql', 'sqlite'], true)) {
throw new \LogicException('Unsupported payables database.');
}
$tables = ['pa_sources', 'pa_proposals', 'pa_obligations', 'pa_events', 'pa_outbox', 'pa_commands'];
$all = [...$tables, 'pa_access'];
$existing = array_filter($all, fn ($table) => Schema::hasTable($table));
if ($existing) {
throw new \LogicException('Payables tables already exist; explicit schema reconciliation required.');
}
$scope = function (Blueprint $t) {
foreach (['tenant_id', 'company_id', 'unit_id'] as $k) {
$t->unsignedBigInteger($k);
}
};
Schema::create('pa_access', function (Blueprint $t) use ($scope) {
$t->id();
$scope($t);
$t->unsignedBigInteger('user_id');
$t->string('person');
$t->string('action');
$t->unique(['tenant_id', 'company_id', 'unit_id', 'user_id', 'action']);
});
Schema::create('pa_sources', function (Blueprint $t) use ($scope) {
$t->id();
$scope($t);
$t->string('source_key', 64)->unique();
$t->unsignedBigInteger('local_source_id');
$t->timestamp('created_at');
});
Schema::create('pa_proposals', function (Blueprint $t) use ($scope) {
$t->id();
$scope($t);
$t->foreignId('source_id')->constrained('pa_sources');
$t->string('source_revision', 64);
$t->integer('capture_version');
$t->jsonb('snapshot');
$t->jsonb('missing');
$t->unsignedBigInteger('prepared_by');
$t->string('prepared_person');
$t->text('reason');
$t->timestamp('created_at');
$t->unique(['source_id', 'capture_version']);
});
Schema::create('pa_obligations', function (Blueprint $t) use ($scope) {
$t->id();
$scope($t);
$t->foreignId('source_id')->unique()->constrained('pa_sources');
$t->foreignId('proposal_id')->constrained('pa_proposals');
$t->string('source_revision', 64);
$t->bigInteger('principal_minor');
$t->string('currency', 3);
$t->date('due_on')->nullable();
$t->unsignedBigInteger('recognised_by');
$t->string('recognised_person');
$t->text('reason');
$t->timestamp('created_at');
});
Schema::create('pa_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('pa_events', function (Blueprint $t) use ($scope) {
$t->id();
$scope($t);
$t->foreignId('source_id')->constrained('pa_sources');
$t->unsignedBigInteger('actor_id');
$t->string('kind');
$t->jsonb('payload');
$t->timestamp('created_at');
});
Schema::create('pa_outbox', function (Blueprint $t) {
$t->foreignId('event_id')->primary()->constrained('pa_events');
$t->string('state');
});
if ($driver === 'pgsql') {
DB::unprepared("CREATE FUNCTION pa_append_only() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'PA_APPEND_ONLY'; END $$");
foreach ($tables as $table) {
DB::unprepared("CREATE TRIGGER pa_history_immutable BEFORE UPDATE OR DELETE ON {$table} FOR EACH ROW EXECUTE FUNCTION pa_append_only()");
}
} else {
foreach ($tables as $table) {
foreach (['UPDATE', 'DELETE'] as $operation) {
$trigger = $table.'_immutable_'.strtolower($operation);
DB::unprepared("CREATE TRIGGER {$trigger} BEFORE {$operation} ON {$table} BEGIN SELECT RAISE(ABORT, 'PA_APPEND_ONLY'); END");
}
}
}
}
}
@@ -0,0 +1,40 @@
<?php
namespace Tempo\Finance\Payables;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class PaymentDecisionSchema
{
public static function install(): void
{
$driver = DB::connection()->getDriverName();
if (! in_array($driver, ['pgsql', 'sqlite'], true) || ! Schema::hasTable('pa_imported_payments') || Schema::hasTable('pa_payment_decisions')) {
throw new \LogicException('Histórico anterior obrigatório; não adoptar tabela de decisões existente.');
}
Schema::create('pa_payment_decisions', function (Blueprint $table) {
$table->id();
foreach (['tenant_id', 'company_id', 'unit_id'] as $key) {
$table->unsignedBigInteger($key);
}
$table->foreignId('obligation_id')->constrained('pa_obligations');
$table->foreignId('imported_payment_id')->unique()->constrained('pa_imported_payments');
$table->unsignedInteger('sequence');
$table->string('previous_hash', 64);
$table->string('history_hash', 64);
$table->jsonb('payment_snapshot');
$table->unsignedBigInteger('actor_id');
$table->timestamp('created_at');
$table->unique(['obligation_id', 'sequence']);
});
if ($driver === 'pgsql') {
DB::unprepared('CREATE TRIGGER pa_decision_immutable BEFORE UPDATE OR DELETE ON pa_payment_decisions FOR EACH ROW EXECUTE FUNCTION pa_append_only()');
} else {
foreach (['UPDATE', 'DELETE'] as $operation) {
DB::unprepared('CREATE TRIGGER pa_decision_'.strtolower($operation)." BEFORE {$operation} ON pa_payment_decisions BEGIN SELECT RAISE(ABORT, 'PA_APPEND_ONLY'); END");
}
}
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace Tempo\Finance\Payables;
use Illuminate\Database\ConnectionInterface;
/** Immutable final decisions on payments that were pending when imported. */
final class PaymentDecisions
{
public function __construct(private ConnectionInterface $db, private array $scope) {}
private function rebuild(array $capture): array
{
unset($capture['history_hash']);
foreach (['confirmed', 'pending', 'rejected'] as $status) {
$capture[$status.'_minor'] = 0;
}
foreach ($capture['payments'] as $payment) {
$key = $payment['status'].'_minor';
if (! array_key_exists($key, $capture) || $payment['amount_minor'] < 1 || $capture[$key] > PHP_INT_MAX - $payment['amount_minor']) {
throw new \DomainException('Totais de decisões incompatíveis.');
}
$capture[$key] += $payment['amount_minor'];
}
return $capture + ['history_hash' => hash('sha256', Payables::canonical($capture))];
}
private function transition(array $capture, array $next, int $actor): array
{
foreach ($capture['payments'] as $index => $payment) {
if ($payment['id'] !== ($next['id'] ?? null)) {
continue;
}
$unchanged = ['id', 'company', 'document', 'amount_minor', 'currency', 'created_by', 'paid_on', 'registration_audit_id', 'payment_key'];
if ($payment['status'] !== 'pending' || ! in_array($next['status'] ?? null, ['confirmed', 'rejected'], true)
|| ($next['reviewed_by'] ?? null) !== $actor || $actor === $payment['created_by']
|| ! is_int($next['decision_audit_id'] ?? null) || $next['decision_audit_id'] < 1 || empty($next['reviewed_at'])
|| count($payment) !== count($next) || array_diff_key($payment, $next) !== []) {
throw new \DomainException('Decisão histórica inválida ou já final.');
}
foreach ($unchanged as $key) {
if ($payment[$key] !== $next[$key]) {
throw new \DomainException('A decisão não pode reescrever o pagamento original.');
}
}
$capture['payments'][$index] = $next;
return $this->rebuild($capture);
}
throw new \DomainException('Pagamento ausente do histórico importado.');
}
public function current(object $import): array
{
$capture = json_decode($import->snapshot, true, 64, JSON_THROW_ON_ERROR);
if (Payables::canonical($this->rebuild($capture)) !== Payables::canonical($capture)
|| ! (new HistoricalPayments($this->db, $this->scope))->matches($import, $capture)) {
throw new \DomainException('Fotografia original da importação incompatível.');
}
if (! $this->db->getSchemaBuilder()->hasTable('pa_payment_decisions')) {
return $capture;
}
$sequence = 0;
foreach ($this->db->table('pa_payment_decisions')->where($this->scope)->where('obligation_id', $import->obligation_id)->orderBy('sequence')->get() as $decision) {
$next = json_decode($decision->payment_snapshot, true, 64, JSON_THROW_ON_ERROR);
$link = $this->db->table('pa_imported_payments')->where($this->scope)->where('obligation_id', $import->obligation_id)->where('id', $decision->imported_payment_id)->first();
if ((int) $decision->sequence !== ++$sequence || $decision->previous_hash !== $capture['history_hash'] || ! $link || (int) $link->legacy_payment_id !== $next['id']) {
throw new \DomainException('Cadeia de decisões históricas incompatível.');
}
$capture = $this->transition($capture, $next, (int) $decision->actor_id);
if ($decision->history_hash !== $capture['history_hash']) {
throw new \DomainException('Decisão histórica divergente.');
}
}
return $capture;
}
public function record(object $import, array $after, int $paymentId, int $actor): void
{
if ($this->db->transactionLevel() === 0 || ! $this->db->getSchemaBuilder()->hasTable('pa_payment_decisions')) {
throw new \LogicException('Esquema e transacção obrigatórios para decidir histórico.');
}
$before = $this->current($import);
$next = null;
foreach ($after['payments'] as $payment) {
if ($payment['id'] === $paymentId) {
$next = $payment;
}
}
if (! $next) {
throw new \DomainException('Pagamento ausente da nova fotografia.');
}
$expected = $this->transition($before, $next, $actor);
if (Payables::canonical($expected) !== Payables::canonical($after)) {
throw new \DomainException('Alterações fora da decisão pendente; operação recusada.');
}
$link = $this->db->table('pa_imported_payments')->where($this->scope)->where('obligation_id', $import->obligation_id)->where('legacy_payment_id', $paymentId)->first();
if (! $link || $link->status !== 'pending') {
throw new \DomainException('Apenas pendentes originalmente importados podem ser decididos.');
}
$sequence = 1 + (int) $this->db->table('pa_payment_decisions')->where($this->scope)->where('obligation_id', $import->obligation_id)->max('sequence');
$this->db->table('pa_payment_decisions')->insert($this->scope + ['obligation_id' => $import->obligation_id, 'imported_payment_id' => $link->id,
'sequence' => $sequence, 'previous_hash' => $before['history_hash'], 'history_hash' => $after['history_hash'],
'payment_snapshot' => Payables::canonical($next), 'actor_id' => $actor, 'created_at' => date('Y-m-d H:i:s')]);
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace Tempo\Finance\Payables;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;
/** Shared presentation/controller; host supplies its existing organisation/permission adapter. */
abstract class SettlementHttp extends Controller
{
protected const MODE = '';
protected const SERVICE = '';
protected function guard(): void
{
$db = DB::connection();
$prefix = static::MODE === 'compras' ? 'compras_receipts_test_' : 'payable_sgf_';
abort_unless(app()->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<string,mixed> */
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());
}
}
}
+136
View File
@@ -0,0 +1,136 @@
<?php
namespace Tempo\Finance\Payables;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/** Additive schema. Called explicitly in isolated QA only, never by host migrations. */
final class SettlementSchema
{
public const TABLES = ['ps_payments', 'ps_payment_decisions', 'ps_proposals', 'ps_allocation_aliases', 'ps_allocation_decisions', 'ps_allocations', 'ps_commands', 'ps_events', 'ps_outbox'];
public static function install(): void
{
$db = DB::connection();
if ($db->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)");
}
});
}
}
@@ -0,0 +1,383 @@
<?php
namespace Tempo\Finance\Payables;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
/** Synthetic payment facts and allocations. No session, bank execution or accounting. */
final class SupplierSettlements
{
/**
* @param array<string,int> $scope
* @param \Closure(string):void $authorize
* @param \Closure(int):array<string,mixed> $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<array-key,mixed> $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<string,mixed> $data
* @param list<string> $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<string,mixed> $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<string,int> $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<string,mixed> $data
* @return array<string,int|bool>
*/
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<string,mixed> $d
* @return array<string,int>
*/
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<string,mixed> $d
* @return array<string,int>
*/
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<string,mixed> $d
* @return array<string,int>
*/
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<array<string,mixed>> $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<string,mixed> $d
* @return array<string,int>
*/
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<string,mixed> */
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];
});
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
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();
require __DIR__.'/../ui/page.php';
return ob_get_clean();
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php $e=fn($v)=>htmlspecialchars((string)$v,ENT_QUOTES|ENT_SUBSTITUTE,'UTF-8'); $money=fn($v,$currency='MZN')=>number_format(intdiv((int)$v,100),0,'.',' ').'.'.str_pad((string)abs((int)$v%100),2,'0',STR_PAD_LEFT).' '.$e($currency); ?>
<!doctype html><html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Obrigações a fornecedores — ensaio</title>
<style>body{font:16px system-ui;background:#f3f6f5;color:#142b28;margin:0}main{max-width:1100px;margin:auto;padding:24px}header,section{background:white;padding:24px;margin:18px 0;border:1px solid #d5e0dc;border-radius:12px}h1{font-size:28px}h2{font-size:20px}.notice{padding:16px;background:#fff2cc}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px}label{display:block;margin:12px 0}select,textarea,button{font:inherit;padding:10px;max-width:100%}textarea{display:block;width:90%}button{background:#125a4b;color:white;border:0;border-radius:6px;cursor:pointer}button:disabled{opacity:.5}table{border-collapse:collapse;width:100%}td,th{text-align:left;padding:12px;border-bottom:1px solid #ddd}.scroll{overflow:auto}.amount{font-size:26px;font-weight:bold}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:#125a4b}</style></head><body><main>
<header><?php if(session()->get('payables_demo_entry')): ?><p><a href="/demo">Trocar função ou sair da demonstração</a></p><?php endif ?><a href="<?= $e($settings['return_url']??'/') ?>">← <?= $e($settings['return_label']??'Voltar à aplicação') ?></a><p><?= $e($settings['host']) ?> · <?= $e($settings['actor']) ?></p><h1>Obrigações a fornecedores</h1><p><?= $e($settings['organisation']) ?></p><p class="notice">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.</p><p>Fornecedor, montantes e conferência são reutilizados da factura. Vencimento desconhecido não é substituído por uma data ou zero dias de atraso.</p></header>
<?php if($data['payments_available']??false): ?><p><a href="/supplier-payments">Pagamentos e afectações sintéticas — sem execução bancária</a></p><?php endif ?><p id="status" role="status"></p><p id="error" role="alert"></p><button id="reload">Actualizar consulta</button>
<section><h2>Facturas de origem</h2><div class="scroll"><table><thead><tr><th>Documento</th><th>Fornecedor</th><th>Base</th><th>Imposto documental</th><th>Total</th><th>Conferência / revisão</th></tr></thead><tbody>
<?php foreach($data['documents'] as $d): ?><tr><td><?= $e($d['number']??'Indeterminado') ?></td><td><?= $e($d['supplier_name']??'Indeterminado') ?></td><td><?= isset($d['subtotal_minor'])?$money($d['subtotal_minor']):'Indeterminado' ?></td><td><?= isset($d['tax_minor'])?$money($d['tax_minor']):'Indeterminado' ?></td><td><?= isset($d['total_minor'])?$money($d['total_minor']):'Indeterminado' ?></td><td><?= $e($d['status']) ?><details><summary>Origem e pendências</summary><pre><?= $e(json_encode($d,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)) ?></pre></details></td></tr><?php endforeach ?>
</tbody></table></div></section>
<?php if($data['permissions']['prepare']): ?><section><h2>Preparar a obrigação</h2><form data-action="prepare"><label>Factura existente<select name="id"><?php foreach($data['documents'] as $d): ?><option value="<?= $e($d['local_id']) ?>"><?= $e($d['number']) ?> · <?= $money($d['total_minor']) ?></option><?php endforeach ?></select></label><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000">Proposta sintética com base na factura conferida.</textarea></label><p>Sem edição de fornecedor, valor ou imposto. Vencimento mantém-se desconhecido neste recorte.</p><button>Preparar da origem</button></form></section><?php endif ?>
<?php $historyDocuments=array_filter($data['documents'],fn($d)=>($d['payment_count']??0)>0); if(($data['history_import_available']??false)&&$data['permissions']['prepare']&&$historyDocuments): ?><section><h2>Incorporar factura com pagamentos anteriores</h2><p>Preserva o principal integral e liga os pagamentos originais uma única vez. Só os confirmados reduzem o saldo; pendentes e rejeitados mantêm o seu estado. Não executa nem confirma pagamentos.</p><form data-action="prepare_history"><label>Factura com histórico<select name="id"><?php foreach($historyDocuments as $d): ?><option value="<?= $e($d['local_id']) ?>"><?= $e($d['number']) ?> · <?= $money($d['total_minor']) ?></option><?php endforeach ?></select></label><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000">Passagem de responsabilidade com preservação dos pagamentos históricos.</textarea></label><button>Preparar passagem com histórico</button></form></section><?php endif ?>
<section><h2>Propostas e decisões</h2><?php if(!$data['proposals']->count()): ?><p>Ainda não há propostas.</p><?php endif ?>
<?php foreach($data['proposals'] as $p): ?><article><h3>Proposta #<?= $e($p->id) ?> · captura <?= $e($p->capture_version) ?></h3><p>Preparador #<?= $e($p->prepared_by) ?> · <?= $e($p->created_at) ?></p><p><?= $e($p->reason) ?></p><details><summary>Fotografia e pendências</summary><pre><?= $e($p->snapshot) ?></pre><pre><?= $e($p->missing) ?></pre></details>
<?php $history=json_decode($p->snapshot,true)['_history_import']??null; if($history): ?><p>Histórico proposto: confirmados <?= $money($history['confirmed_minor']) ?> · pendentes <?= $money($history['pending_minor']) ?> · rejeitados <?= $money($history['rejected_minor']) ?>. Os pendentes e rejeitados não reduzem o saldo.</p><?php endif ?>
<?php if($data['permissions']['validate']&&(!$history||($data['history_import_available']??false))): ?><form data-action="<?= $history?'validate_history':'validate' ?>"><input type="hidden" name="id" value="<?= $e($p->id) ?>"><label>Fundamento da decisão<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button><?= $history?'Validar passagem e preservar histórico':'Validar revisão exacta e reconhecer' ?></button></form><?php endif ?></article><?php endforeach ?></section>
<section><h2>Obrigações reconhecidas</h2><?php if(!$data['obligations']): ?><p>Nenhuma obrigação reconhecida. Proposta ou factura conferida não é decisão de reconhecimento.</p><?php endif ?><div class="grid">
<?php foreach($data['obligations'] as $o): ?><article><h3>Obrigação #<?= $e($o['id']) ?></h3><p class="amount"><?= $money($o['principal_minor']) ?></p><p>Principal reconhecido — vencimento pendente</p><?php if($o['history_imported']??false): ?><p>Pagamentos históricos confirmados: <?= $money($o['confirmed_payments_minor']) ?></p><p>Pendentes: <?= $money($o['pending_payments_minor']) ?> · Rejeitados: <?= $money($o['rejected_payments_minor']) ?> — sem abatimento.</p><p><strong>Saldo por liquidar: <?= $o['balance_minor']===null?'Pendente de revisão':$money($o['balance_minor']) ?></strong></p><?php if($data['history_decisions_available']??false): ?><p>Importação original preservada. A conferência independente dos pendentes actualiza este saldo uma única vez.</p><p><a href="/pagamentos">Conferir pagamentos pendentes →</a></p><?php else: ?><p>Importação original preservada. Conferência de pendentes não activada nesta instalação.</p><?php endif ?><?php endif ?><?php if($o['source_conflict']??false): ?><p role="alert">Origem ou histórico divergente; saldo não presumido. O principal original permanece preservado.</p><?php endif ?><p>Atraso indeterminado · Dias de atraso: sem valor</p><p>Validador #<?= $e($o['recognised_by']) ?> · <?= $e($o['created_at']) ?></p><details><summary>Origem, versão e decisão</summary><pre><?= $e(json_encode($o,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)) ?></pre></details></article><?php endforeach ?></div></section>
<section><h2>Histórico durável</h2><?php foreach($data['events'] as $event): ?><details><summary>#<?= $e($event->id) ?> · <?= $e($event->kind) ?> · actor #<?= $e($event->actor_id) ?> · <?= $e($event->created_at) ?></summary><pre><?= $e($event->payload) ?></pre></details><?php endforeach ?></section>
<p>Sem execução de novos pagamentos, cobrança, efeitos de stock, novo consumo orçamental ou lançamento automático. A passagem de histórico não confirma pagamentos pendentes.</p>
</main><script>
const settings=<?= json_encode($settings,JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT|JSON_THROW_ON_ERROR) ?>;
document.querySelector('#reload').onclick=()=>location.reload();
for(const form of document.querySelectorAll('form[data-action]'))form.onsubmit=async event=>{event.preventDefault();const body=Object.fromEntries(new FormData(form));body.id=Number(body.id);const signature=JSON.stringify(body);if(form.dataset.signature!==signature){form.dataset.signature=signature;form.dataset.key=crypto.randomUUID();}const button=form.querySelector('button');button.disabled=true;document.querySelector('#error').textContent='';try{const r=await fetch(settings.api+'/'+form.dataset.action,{method:'POST',credentials:'same-origin',headers:{Accept:'application/json','Content-Type':'application/json','X-CSRF-TOKEN':settings.csrf,'X-SGF-Context':settings.context??'','Idempotency-Key':form.dataset.key},body:JSON.stringify(body)});const d=await r.json();if(!r.ok)throw new Error(d.message??Object.values(d.errors??{}).flat().join(' '));if(d.state==='conflict'){document.querySelector('#error').textContent=d.reason+' Consulte o histórico.';return;}location.reload();}catch(e){document.querySelector('#error').textContent=e.message;}finally{button.disabled=false;}};
</script></body></html>
+32
View File
@@ -0,0 +1,32 @@
<?php
$e = static fn ($v) => 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 '<option value="'.$e($o['id']).'" data-revision="'.$e($o['source_revision']).'">#'.$e($o['id']).' · '.$e($o['origin_snapshot']['supplier_name'] ?? 'Fornecedor').' · '.$e($money($o['balance_minor'])).'</option>';
};
?>
<!doctype html><html lang="pt"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Pagamentos e afectações a fornecedores — ensaio</title><style>
body{font:17px system-ui;color:#172f29;background:#f4f6f5;margin:0}main{max-width:1100px;margin:auto;padding:24px}section,header{background:white;padding:24px;border:1px solid #dbe4df;border-radius:12px;margin:18px 0}h1{font-size:28px}h2{font-size:22px}p,li{line-height:1.6}label{display:block;margin:14px 0}input,textarea,select,button{font:inherit;padding:10px;max-width:100%}input,textarea,select{display:block;width:min(100%,600px);box-sizing:border-box}button{background:#125a4b;color:white;border:0;border-radius:7px;margin:6px;cursor:pointer}button:disabled{opacity:.5;cursor:wait}.notice{background:#fff3cf;padding:16px}a{color:#125a4b}.scroll{overflow:auto}table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:12px;border-bottom:1px solid #ddd}pre{white-space:pre-wrap;overflow-wrap:anywhere}.line{border:1px solid #ddd;padding:16px;margin:12px 0}#error{color:#8d251e}article{border-top:1px solid #ddd;padding:16px 0}
</style></head><body><main>
<header><a href="<?= $e($settings['obligations_url']) ?>">← Obrigações a fornecedores</a> · <a href="/demo">Trocar função ou sair</a><p><?= $e($settings['host']) ?> · <?= $e($settings['actor']) ?></p><h1>Pagamentos e afectações a fornecedores</h1><p class="notice"><strong>ENSAIO SINTÉTICO — SEM MOVIMENTAÇÃO DE DINHEIRO.</strong> 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.</p><p>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.</p></header>
<p id="status" role="status"></p><p id="error" role="alert"></p><button id="reload">Actualizar consulta</button>
<section><h2>Obrigações e saldo canónico</h2><div class="scroll"><table><thead><tr><th>Origem / fornecedor</th><th>Principal</th><th>Afectado neste percurso</th><th>Saldo</th><th>Vencimento</th></tr></thead><tbody><?php foreach ($data['obligations'] as $o): ?><tr><td>#<?= $e($o['id']) ?> · <?= $e($o['origin_snapshot']['number'] ?? '') ?><br><?= $e($o['origin_snapshot']['supplier_name'] ?? '') ?><details><summary>Versão e decisão</summary><pre><?= $e(json_encode($o, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) ?></pre></details></td><td><?= $money($o['principal_minor']) ?></td><td><?= $money($o['allocated_payments_minor']) ?></td><td><?= $money($o['balance_minor']) ?><?= $o['source_conflict'] ? ' — origem divergente' : '' ?></td><td>Pendente<br>Atraso indeterminado<br>Dias de atraso: sem valor</td></tr><?php endforeach ?></tbody></table></div><?php if (! $data['obligations']): ?><p>Reconheça primeiro a obrigação no percurso existente. Não é criada uma dívida por registar um pagamento.</p><?php endif ?></section>
<?php if ($data['permissions']['payment_prepare'] && $eligible): ?><section><h2>1. Propor pagamento sintético</h2><form data-action="payment-prepare"><label>Obrigação de referência / fornecedor<select name="obligation_id" data-origin><?php $options(); ?></select></label><input type="hidden" name="source_revision"><label>Montante do pagamento (MZN)<input name="amount" required inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?" placeholder="4000.00"></label><input type="hidden" name="currency" value="MZN"><label>Data factual do pagamento simulado<input type="date" name="paid_on" required></label><label>Referência estável do facto<input name="fact_reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}" placeholder="SIM-PAY-001"></label><label>Evidência artificial de origem<input name="evidence_reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}" placeholder="SIM-EVID-001"></label><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Propor sem afectar dívida</button></form></section><?php endif ?>
<section><h2>2. Pagamentos propostos e decisões</h2><?php foreach ($data['payments'] as $p): ?><article><h3>Pagamento #<?= $e($p['id']) ?> · <?= $e($p['fact_reference']) ?></h3><p><?= $money($p['amount_minor']) ?> · Data simulada <?= $e($p['paid_on']) ?> · Estado: <?= $e(['proposed'=>'Proposto','confirmed'=>'Confirmado em ensaio','rejected'=>'Rejeitado'][$p['state']]) ?></p><p>Por afectar: <?= $money($p['unallocated_minor']) ?> · Reconciliação bancária: não implementada.</p><details><summary>Preparação e evidência preservadas</summary><pre><?= $e($p['snapshot']) ?></pre><?php if ($p['decision']): ?><pre><?= $e(json_encode($p['decision'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) ?></pre><?php endif ?></details>
<?php if ($data['permissions']['payment_confirm'] && ! $p['decision'] && (int) $p['prepared_by'] !== $data['current_actor']): ?><form data-action="payment-decide"><input type="hidden" name="payment_id" value="<?= $e($p['id']) ?>"><label>Decisão explícita<select name="decision"><option value="confirmed">Confirmar com evidência sintética</option><option value="rejected">Rejeitar</option></select></label><label>Evidência artificial da conferência<input name="evidence_reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}" placeholder="SIM-CONF-001"></label><label>Fundamento da conferência<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Registar decisão independente</button></form><?php endif ?></article><?php endforeach ?><?php if (! $data['payments']): ?><p>Sem pagamentos propostos neste contexto.</p><?php endif ?></section>
<?php if ($data['permissions']['allocation_prepare'] && $confirmed && $eligible): ?><section><h2>3. Propor afectações</h2><form data-action="allocation-prepare"><label>Pagamento confirmado<select name="payment_id"><?php foreach ($confirmed as $p): ?><option value="<?= $e($p['id']) ?>">#<?= $e($p['id']) ?> · <?= $e($p['fact_reference']) ?> · Por afectar <?= $money($p['unallocated_minor']) ?></option><?php endforeach ?></select></label><label>Referência estável da afectação<input name="reference" required pattern="SIM-[A-Z0-9][A-Z0-9._-]{2,95}" placeholder="SIM-ALLOC-001"></label><div data-lines><div class="line" data-line><label>Obrigação<select data-obligation><?php $options(); ?></select></label><label>Valor a afectar (MZN)<input data-amount required inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?" placeholder="4000.00"></label><button type="button" data-remove>Remover linha</button></div></div><button type="button" data-add>Adicionar outra obrigação</button><label>Fundamento<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><p>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.</p><button>Submeter afectações sem abater saldo</button></form></section><?php endif ?>
<section><h2>4. Propostas de afectação</h2><?php foreach ($data['allocation_proposals'] as $p): ?><article><h3>Proposta #<?= $e($p['id']) ?> · <?= $e($p['reference']) ?> · Pagamento #<?= $e($p['payment_id']) ?></h3><p>Preparador #<?= $e($p['prepared_by']) ?> · <?= $e($p['reason']) ?></p><ul><?php foreach (json_decode($p['plan'], true) as $line): ?><li>Obrigação #<?= $e($line['obligation_id']) ?>: <?= $money($line['amount_minor']) ?> · versão <?= $e($line['source_revision']) ?></li><?php endforeach ?></ul><p>Estado: <?= $p['decision'] ? $e($p['decision']->decision === 'confirmed' ? 'Afectação confirmada' : 'Rejeitada') : 'Proposta — sem efeito no saldo' ?></p>
<?php if ($data['permissions']['allocation_confirm'] && ! $p['decision'] && (int) $p['prepared_by'] !== $data['current_actor']): ?><form data-action="allocation-decide"><input type="hidden" name="proposal_id" value="<?= $e($p['id']) ?>"><label>Decisão<select name="decision"><option value="confirmed">Confirmar estas afectações</option><option value="rejected">Rejeitar</option></select></label><label>Fundamento da decisão<textarea name="reason" required minlength="3" maxlength="2000"></textarea></label><button>Confirmar decisão e revalidar saldos</button></form><?php endif ?></article><?php endforeach ?></section>
<section><h2>Histórico durável</h2><?php foreach ($data['events'] as $event): ?><details><summary>#<?= $e($event->id) ?> · <?= $e($event->kind) ?> · actor #<?= $e($event->actor_id) ?> · <?= $e($event->created_at) ?></summary><pre><?= $e($event->payload) ?></pre></details><?php endforeach ?></section>
<p>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.</p>
</main><script>
const settings=<?= json_encode($settings, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?>;
document.querySelector('#reload').onclick=()=>location.reload();
for(const selector of document.querySelectorAll('[data-origin]')){const sync=()=>selector.form.elements.source_revision.value=selector.selectedOptions[0]?.dataset.revision??'';selector.onchange=sync;sync();}
for(const form of document.querySelectorAll('[data-action]')){
const add=form.querySelector('[data-add]');if(add){const lines=form.querySelector('[data-lines]');const template=lines.firstElementChild.cloneNode(true);add.onclick=()=>{if(lines.children.length<50)lines.append(template.cloneNode(true));};lines.onclick=e=>{if(e.target.matches('[data-remove]')&&lines.children.length>1)e.target.closest('[data-line]').remove();};}
form.onsubmit=async e=>{e.preventDefault();let body=Object.fromEntries(new FormData(form));for(const key of ['obligation_id','payment_id','proposal_id'])if(key in body)body[key]=Number(body[key]);if(form.dataset.action==='allocation-prepare')body.allocations=[...form.querySelectorAll('[data-line]')].map(line=>{const select=line.querySelector('[data-obligation]');return {obligation_id:Number(select.value),source_revision:select.selectedOptions[0]?.dataset.revision??'',amount:line.querySelector('[data-amount]').value};});const signature=JSON.stringify(body);if(form.dataset.signature!==signature){form.dataset.signature=signature;form.dataset.key=crypto.randomUUID();}const buttons=[...form.querySelectorAll('button')];buttons.forEach(b=>b.disabled=true);document.querySelector('#error').textContent='';try{const r=await fetch(settings.api+'/'+form.dataset.action,{method:'POST',credentials:'same-origin',headers:{Accept:'application/json','Content-Type':'application/json','X-CSRF-TOKEN':settings.csrf,'X-SGF-Context':settings.context??'','Idempotency-Key':form.dataset.key},body:signature});const d=await r.json();if(!r.ok){document.querySelector('#error').textContent=d.message??'Não foi possível concluir; consulte o histórico.';return;}location.reload();}catch{document.querySelector('#error').textContent='Não foi possível confirmar o resultado. Actualize a consulta e confira o histórico antes de repetir.';}finally{buttons.forEach(b=>b.disabled=false);}};
}
</script></body></html>