commit3
This commit is contained in:
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user