Accept technical extraction of shared budget core with QA limits

This commit is contained in:
2026-10-29 13:25:05 +02:00
parent d071bc03d3
commit f77900e198
15 changed files with 1220 additions and 0 deletions
@@ -0,0 +1,19 @@
<?php
namespace Tempo\Finance\Budget;
final readonly class BudgetContext
{
/** @param array<string, int> $scope Explicit host identifiers, never inferred from each other. */
public function __construct(public int $actorId, public array $scope, public int $year, public string $currency)
{
if ($actorId < 1 || $scope === [] || $year < 2026 || $currency !== 'MZN') {
throw new \InvalidArgumentException('Contexto orçamental ou moeda não suportados.');
}
foreach ($scope as $column => $id) {
if (! in_array($column, ['tenant_id', 'company_id', 'unit_id'], true) || ! is_int($id) || $id < 1) {
throw new \InvalidArgumentException('Âmbito explícito inválido.');
}
}
}
}
@@ -0,0 +1,291 @@
<?php
namespace Tempo\Finance\Budget;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Query\Builder;
/**
* Single writer of allocation, reinforcement and procurement budget effects.
* Hosts supply authentication/authorisation and organisation locks, not financial rules.
* All money is the existing exact integer minor unit; no tax/price rounding here.
*/
final class BudgetControl
{
public const VERSION = 'COMMON-BUDGET-SYNTHETIC-v1';
public const MAX_MINOR = 100000000000000;
public function __construct(
private ConnectionInterface $db,
public readonly BudgetContext $context,
private string $prefix,
private \Closure $lockAndAuthorize,
private \Closure $audit,
private \Closure $eligibleAuthor,
) {
if (! preg_match('/^[a-z_]*$/D', $prefix)) {
throw new \InvalidArgumentException('Invalid budget table prefix.');
}
}
private function query(string $table): Builder
{
return $this->db->table($this->prefix.$table)->where($this->context->scope);
}
private function atomic(string $action, callable $work): mixed
{
return $this->db->transaction(function () use ($action, $work) {
($this->lockAndAuthorize)($action, $this->context);
return $work();
});
}
private function fail(string $message, string $field = 'budget', int $status = 422): never
{
throw new BudgetFailure($message, $field, $status);
}
private function allocation(int $id): object
{
return $this->query('budgets')->where('id', $id)->lockForUpdate()->first()
?? $this->fail('Dotação fora do âmbito autorizado.', status: 403);
}
private function stamp(): string
{
return date('Y-m-d H:i:s');
}
private function emit(string $action, array $data, ?int $origin = null): void
{
($this->audit)($action, $data, $origin, $this->context);
}
public static function approvedTotal(int $initial, int $reinforcements): int
{
$sum = $initial + $reinforcements;
if (! is_int($sum)) {
throw new \OverflowException('Montante orçamental não representável.');
}
return $sum;
}
private function total(object $budget): int
{
return self::approvedTotal((int) $budget->amount_minor, (int) $this->query('budget_reinforcements')->where('budget_id', $budget->id)->where('status', 'approved')->sum('amount_minor'));
}
/** Host validates the cost centre and input format; original integer amount is preserved. */
public function create(array $data): int
{
return $this->atomic('create', function () use ($data) {
$fields = ['cost_center_id', 'year', 'kind', 'includes_tax', 'amount_minor', 'reason'];
if (array_diff(array_keys($data), $fields) !== [] || array_diff($fields, array_keys($data)) !== []
|| ! is_int($data['cost_center_id']) || $data['cost_center_id'] < 1
|| ! is_int($data['year']) || $data['year'] < 2026 || $data['year'] > 2100
|| ! is_bool($data['includes_tax']) || ! is_string($data['reason']) || trim($data['reason']) === '') {
$this->fail('Dados de dotação inválidos ou campos protegidos.');
}
if ($this->query('budgets')->where('cost_center_id', $data['cost_center_id'])->where('year', $data['year'])->exists()) {
$this->fail('Já existe dotação para este centro e exercício.', status: 409);
}
if (! is_int($data['amount_minor']) || $data['amount_minor'] < 0 || $data['amount_minor'] > self::MAX_MINOR || ! in_array($data['kind'], ['operating', 'investment'], true)) {
$this->fail('Dotação não representável.');
}
$id = $this->query('budgets')->insertGetId($this->context->scope + $data + ['created_by' => $this->context->actorId, 'created_at' => $this->stamp(), 'updated_at' => $this->stamp()]);
$this->emit('budget.created', (array) $this->allocation($id));
return $id;
});
}
public function approve(int $id): void
{
$this->atomic('approve', function () use ($id) {
$budget = $this->allocation($id);
if ((int) $budget->created_by === $this->context->actorId) {
$this->fail('Aprovação exige identidade distinta.', status: 403);
}
if ($budget->approved_by) {
return;
}
$this->query('budgets')->where('id', $id)->update(['approved_by' => $this->context->actorId, 'approved_at' => $this->stamp(), 'updated_at' => $this->stamp()]);
$this->emit('budget.approved', (array) $this->allocation($id));
});
}
/** @return array<string, int>|null */
public function record(int $origin, int $centre, string $requiredKind, string $kind, int $amount = 0, ?int $subtotal = null): ?array
{
return $this->atomic('record', function () use ($origin, $centre, $requiredKind, $kind, $amount, $subtotal) {
if (! in_array($kind, ['reserve', 'award', 'commit', 'release'], true) || $amount < 0) {
$this->fail('Movimento orçamental inválido.');
}
$own = $this->query('budget_entries')->where('requisition_id', $origin);
$first = (clone $own)->orderBy('id')->first();
$budget = $first ? $this->allocation((int) $first->budget_id) : $this->query('budgets')->where('cost_center_id', $centre)->where('year', $this->context->year)->lockForUpdate()->first();
// Exact legacy behaviour: unconfigured development documents are explicitly uncontrolled.
if (! $budget) {
return null;
}
if ($budget->kind !== $requiredKind) {
$this->fail('A dotação deve ter a classificação explícita adequada: investimento para processos com equipamento; corrente para os restantes.');
}
if (! $budget->approved_by || ($kind !== 'release' && (int) $budget->year !== $this->context->year)) {
$this->fail('Dotação pendente de aprovação ou exercício encerrado.');
}
if (! $budget->includes_tax && in_array($kind, ['reserve', 'award', 'commit'], true)) {
$amount = $subtotal ?? throw new \LogicException('Subtotal necessário para a base sem IVA.');
}
$result = ['budget_id' => (int) $budget->id, 'year' => (int) $budget->year];
$old = (clone $own)->where('kind', $kind)->first();
if ($old) {
// Reconstruct original target before this entry, including historical rows without hashes.
$before = (int) (clone $own)->where('id', '<', $old->id)->sum('reserved_minor');
$target = $kind === 'commit' ? (int) $old->committed_minor : $before + (int) $old->reserved_minor;
if ($kind !== 'release' && $target !== $amount) {
$this->fail('Origem já registada com outro montante; não é criado segundo efeito.');
}
return $result;
}
$reserved = (int) (clone $own)->sum('reserved_minor');
$committed = (int) (clone $own)->sum('committed_minor');
if ($committed !== 0 || ($kind !== 'reserve' && ! $first)) {
$this->fail('Processo sem reserva válida ou já comprometido.');
}
[$rd, $cd] = match ($kind) {
'reserve', 'award' => [$amount - $reserved, 0],
'release' => [-$reserved, 0],
'commit' => [-$reserved, $amount],
};
$balance = $this->balanceOf($budget);
if ($balance['available_minor'] < $rd + $cd) {
$this->fail('Saldo orçamental insuficiente. Não são permitidos excessos.');
}
$this->query('budget_entries')->insert($this->context->scope + ['budget_id' => $budget->id, 'requisition_id' => $origin, 'created_by' => $this->context->actorId, 'kind' => $kind, 'reserved_minor' => $rd, 'committed_minor' => $cd, 'created_at' => $this->stamp()]);
$this->emit('budget.'.$kind, ['budget_id' => $budget->id, 'reserved_minor' => $rd, 'committed_minor' => $cd], $origin);
return $result;
});
}
/** Only budget consumption: does not create a payable, invoice, payment or accounting entry. */
public function consume(int $origin, int $invoice, int $total, int $subtotal): array
{
return $this->atomic('consume', function () use ($origin, $invoice, $total, $subtotal) {
$own = $this->query('budget_entries')->where('requisition_id', $origin);
$commit = (clone $own)->where('kind', 'commit')->first();
if (! $commit) {
$this->fail('A ordem não possui compromisso orçamental. Factura permanece pendente; não é criado saldo retroactivo.', 'invoice');
}
$budget = $this->allocation((int) $commit->budget_id);
if (! $budget->approved_by || (int) $budget->year !== $this->context->year) {
$this->fail('Orçamento não aprovado ou fora do exercício.', 'invoice');
}
$amount = (int) ($budget->includes_tax ? $total : $subtotal);
$old = $this->query('budget_consumptions')->where('invoice_id', $invoice)->first();
$result = ['budget_id' => (int) $budget->id, 'consumed_minor' => $amount];
if ($old) {
$entry = (clone $own)->where('kind', 'invoice-'.$invoice)->first();
if ((int) $old->budget_id !== (int) $budget->id || (int) $old->amount_minor !== $amount || ! $entry || (int) $entry->committed_minor !== -$amount) {
$this->fail('Factura já consumida com outra origem ou montante.', 'invoice');
}
return $result;
}
if ($amount < 0 || $amount > (int) (clone $own)->sum('committed_minor')) {
$this->fail('Compromisso insuficiente para a factura.', 'invoice');
}
$this->query('budget_entries')->insert($this->context->scope + ['budget_id' => $budget->id, 'requisition_id' => $origin, 'created_by' => $this->context->actorId, 'kind' => 'invoice-'.$invoice, 'reserved_minor' => 0, 'committed_minor' => -$amount, 'created_at' => $this->stamp()]);
$this->query('budget_consumptions')->insert($this->context->scope + ['budget_id' => $budget->id, 'invoice_id' => $invoice, 'created_by' => $this->context->actorId, 'amount_minor' => $amount, 'created_at' => $this->stamp()]);
$this->emit('budget.consumed', $result + ['invoice_id' => $invoice], $origin);
return $result;
});
}
private function validReinforcement(object $budget, int $amount): void
{
if (! $budget->approved_by || (int) $budget->year !== $this->context->year || $amount <= 0) {
$this->fail('Exige dotação aprovada do exercício actual e montante positivo.', 'reinforcement');
}
if ($amount > self::MAX_MINOR - $this->total($budget)) {
$this->fail('A dotação acumulada ultrapassa o limite técnico de valores suportados.', 'reinforcement');
}
}
public function proposeReinforcement(int $id, int $amount, string $reference, string $reason): int
{
return $this->atomic('reinforcement.propose', function () use ($id, $amount, $reference, $reason) {
$budget = $this->allocation($id);
$hash = hash('sha256', json_encode(['budget_id' => $id, 'amount_minor' => $amount, 'reason' => $reason], JSON_THROW_ON_ERROR));
$old = $this->query('budget_reinforcements')->where('reference', $reference)->first();
if ($old) {
if ((int) $old->created_by !== $this->context->actorId || $old->payload_hash !== $hash) {
$this->fail('Referência já registada com outros dados.', 'reference');
}
return (int) $old->id;
}
$this->validReinforcement($budget, $amount);
if ($reference === '' || $reason === '') {
$this->fail('Referência e fundamento obrigatórios.', 'reinforcement');
}
$rid = $this->query('budget_reinforcements')->insertGetId($this->context->scope + ['budget_id' => $id, 'created_by' => $this->context->actorId, 'reference' => $reference, 'payload_hash' => $hash, 'amount_minor' => $amount, 'reason' => $reason, 'status' => 'pending', 'created_at' => $this->stamp(), 'updated_at' => $this->stamp()]);
$this->emit('budget.reinforcement_proposed', ['reinforcement_id' => $rid, 'budget_id' => $id, 'amount_minor' => $amount, 'status' => 'pending']);
return $rid;
});
}
public function reviewReinforcement(int $id, string $decision, string $reason): void
{
$this->atomic('reinforcement.review', function () use ($id, $decision, $reason) {
$r = $this->query('budget_reinforcements')->where('id', $id)->first() ?? $this->fail('Reforço fora do âmbito.', status: 403);
$budget = $this->allocation((int) $r->budget_id);
$r = $this->query('budget_reinforcements')->where('id', $id)->lockForUpdate()->first();
if ((int) $r->created_by === $this->context->actorId) {
$this->fail('Aprovação exige identidade distinta.', status: 403);
}
if (! in_array($decision, ['approved', 'rejected'], true) || trim($reason) === '') {
$this->fail('Decisão e fundamento obrigatórios.', 'reinforcement');
}
if ($r->status !== 'pending') {
if ($r->status !== $decision) {
$this->fail('Decisão final imutável.', 'reinforcement');
}
return;
}
if ($decision === 'approved') {
$this->validReinforcement($budget, (int) $r->amount_minor);
if (! ($this->eligibleAuthor)((int) $r->created_by, $this->context)) {
$this->fail('Autor da proposta deixou de ser elegível.', 'reinforcement');
}
}
$this->query('budget_reinforcements')->where('id', $id)->update(['status' => $decision, 'reviewed_by' => $this->context->actorId, 'review_reason' => trim($reason), 'reviewed_at' => $this->stamp(), 'updated_at' => $this->stamp()]);
$this->emit('budget.reinforcement_'.$decision, ['reinforcement_id' => $id, 'budget_id' => (int) $r->budget_id, 'amount_minor' => (int) $r->amount_minor, 'status' => $decision]);
});
}
private function balanceOf(object $budget): array
{
$entries = $this->query('budget_entries')->where('budget_id', $budget->id);
$reserved = (int) (clone $entries)->sum('reserved_minor');
$committed = (int) (clone $entries)->sum('committed_minor');
$consumed = (int) $this->query('budget_consumptions')->where('budget_id', $budget->id)->sum('amount_minor');
$total = $this->total($budget);
return ['approved_total_minor' => $budget->approved_by ? $total : 0, 'reinforced_minor' => $total - (int) $budget->amount_minor, 'reserved_minor' => $reserved, 'committed_minor' => $committed, 'consumed_minor' => $consumed, 'available_minor' => $budget->approved_by ? $total - $reserved - $committed - $consumed : 0];
}
public function balance(int $id): array
{
return $this->atomic('view', fn () => $this->balanceOf($this->allocation($id)));
}
}
@@ -0,0 +1,11 @@
<?php
namespace Tempo\Finance\Budget;
final class BudgetFailure extends \DomainException
{
public function __construct(string $message, public readonly string $field = 'budget', public readonly int $status = 422)
{
parent::__construct($message);
}
}