This commit is contained in:
2026-10-29 22:42:37 +02:00
parent 78e89a51d2
commit 53ff46b0a2
52 changed files with 2082 additions and 79 deletions
@@ -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");
}
}
}
}
}
+136 -17
View File
@@ -10,7 +10,7 @@ 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 $authorize, private \Closure $loadSource, private ?\Closure $loadHistory = null) {}
public static function canonical(array $data): string
{
@@ -21,7 +21,7 @@ final class Payables
ksort($v);
}
return array_map($sort, $v);
return array_map($sort, $v);
};
return json_encode($sort($data), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
@@ -65,7 +65,7 @@ return array_map($sort, $v);
$m[] = 'Linhas conferidas em falta.';
}
if (! array_key_exists('payment_count', $s) || $s['payment_count'] !== 0) {
$m[] = 'Histórico de pagamentos existente ou desconhecido; migração fora do âmbito.';
$m[] = 'Histórico de pagamentos existente ou desconhecido; exige passagem de responsabilidade própria.';
}
return $m;
@@ -107,18 +107,116 @@ return array_map($sort, $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);
return (array) $obligation + ['state' => 'recognised', 'balance_minor' => $compatible ? (int) $obligation->principal_minor - $confirmed : null,
'source_conflict' => ! $compatible, 'history_imported' => $import !== null, 'confirmed_payments_minor' => $confirmed,
'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 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'], true) || ! preg_match('/^[a-f0-9-]{36}$/Di', $key) || mb_strlen(trim($reason)) < 3 || mb_strlen($reason) > 2000) {
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) {
($this->authorize)($action);
$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) {
@@ -130,25 +228,32 @@ return array_map($sort, $v);
$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 || self::missing($s) !== ($result['missing'] ?? [])) {
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 ($action === 'prepare') {
if ($operation === 'prepare') {
$s = $this->source($id);
$row = $this->locked($s);
$capture = $historical ? $this->history($s) : null;
$revision = self::revision($s);
$missing = self::missing($s);
$missing = $this->eligibility($s, $historical);
$ob = $this->q('obligations')->where('source_id', $row->id)->first();
if ($ob) {
$result = $ob->source_revision === $revision && $missing === [] ? ['state' => 'recognised', 'obligation_id' => $ob->id, 'local_source_id' => $id, 'source_revision' => $revision, 'missing' => []] : $this->conflict($row, $id, 'Documento alterado ou pagamentos associados; nenhuma dívida adicional.');
$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();
if (! $candidate || $candidate->source_revision !== $revision || json_decode($candidate->missing, true) !== $missing) {
$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($s), 'missing' => self::canonical($missing), 'prepared_by' => $this->actor, 'prepared_person' => $this->person, 'reason' => $reason, 'created_at' => date('Y-m-d H:i:s')]);
$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;
@@ -165,21 +270,37 @@ return array_map($sort, $v);
}
$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 = self::missing($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 !== []) {
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) {
$result = ['state' => 'recognised', 'obligation_id' => $ob->id, 'local_source_id' => $local, 'source_revision' => $revision, 'missing' => []];
$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];
@@ -209,10 +330,8 @@ return array_map($sort, $v);
$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;
// Additional UI-derived revision/missing fields are not part of the material source hash.
$compatible = $doc && $doc['revision'] === $o->source_revision && $doc['missing'] === [];
return (array) $o + ['state' => 'recognised', 'balance_minor' => $compatible ? (int) $o->principal_minor : null, 'source_conflict' => ! $compatible, 'due_status' => 'unknown', 'days_overdue' => null, 'documentary_pending' => array_merge(['Vencimento desconhecido; atraso indeterminado.'], $compatible ? [] : ['Origem/eligibilidade divergente; saldo não presumido.'])];
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')]);
}
}