f = receiptFixture(); $f = $this->f; $this->author = User::factory()->create(['tenant_id' => $f['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]); $this->approver = User::factory()->create(['tenant_id' => $f['tenant']->id, 'purchasing_role' => PurchasingRole::Admin]); $this->actingAs($this->author); $this->budget = Budget::query()->create(['tenant_id' => $f['tenant']->id, 'cost_center_id' => $f['r']->cost_center_id, 'year' => now()->year, 'amount_minor' => 20000, 'includes_tax' => true, 'reason' => 'Dotação fictícia', 'created_by' => $this->author->id]); }); function budgetRecord(object $test, string $kind, int $amount = 0, ?int $subtotal = null): mixed { return DB::transaction(function () use ($test, $kind, $amount, $subtotal) { app(ConflictGuard::class)->lockCompany($test->author); return app(BudgetWorkflow::class)->record($test->author, $test->f['r'], $kind, $amount, $subtotal); }); } test('budget requires independent approval and records only once', function () { $this->post('/orcamentos/'.$this->budget->id.'/aprovar')->assertForbidden(); $this->actingAs($this->approver)->post('/orcamentos/'.$this->budget->id.'/aprovar')->assertRedirect(); $this->post('/orcamentos/'.$this->budget->id.'/aprovar')->assertRedirect(); expect($this->budget->refresh()->approved_by)->toBe($this->approver->id); }); test('pending allocation blocks reservation', function () { expect(fn () => budgetRecord($this, 'reserve', 10000))->toThrow(ValidationException::class); expect(BudgetEntry::query()->count())->toBe(0); }); test('reserve award and commitment do not count spending twice', function () { $this->budget->update(['approved_by' => $this->approver->id]); budgetRecord($this, 'reserve', 10000); budgetRecord($this, 'reserve', 10000); budgetRecord($this, 'award', 15000); budgetRecord($this, 'commit', 15000); budgetRecord($this, 'commit', 15000); expect((int) BudgetEntry::query()->sum('reserved_minor'))->toBe(0) ->and((int) BudgetEntry::query()->sum('committed_minor'))->toBe(15000) ->and(BudgetEntry::query()->count())->toBe(3); }); test('over budget adjustments roll back without losing reservation', function () { $this->budget->update(['approved_by' => $this->approver->id]); budgetRecord($this, 'reserve', 10000); expect(fn () => budgetRecord($this, 'award', 20001))->toThrow(ValidationException::class); expect((int) BudgetEntry::query()->sum('reserved_minor'))->toBe(10000); }); test('rejection releases reservation once and history is immutable', function () { $this->budget->update(['approved_by' => $this->approver->id]); budgetRecord($this, 'reserve', 10000); budgetRecord($this, 'release'); budgetRecord($this, 'release'); expect((int) BudgetEntry::query()->sum('reserved_minor'))->toBe(0); expect(fn () => BudgetEntry::query()->firstOrFail()->delete())->toThrow(LogicException::class); }); test('company chooses its own tax basis', function () { $this->budget->update(['approved_by' => $this->approver->id, 'includes_tax' => false]); budgetRecord($this, 'reserve', 11600, 10000); expect((int) BudgetEntry::query()->sum('reserved_minor'))->toBe(10000); }); test('foreign company cannot read or approve allocation', function () { $other = receiptFixture(); $user = User::factory()->create(['tenant_id' => $other['tenant']->id, 'purchasing_role' => PurchasingRole::Finance]); $this->actingAs($user)->post('/orcamentos/'.$this->budget->id.'/aprovar')->assertNotFound(); expect(Budget::query()->count())->toBe(0); }); test('warehouse cannot prepare or consult budgets', function () { $this->actingAs($this->f['receiver'])->get('/orcamentos')->assertForbidden(); $this->post('/orcamentos', [])->assertForbidden(); }); test('allocation amounts and tax basis are mandatory company decisions', function () { $this->budget->delete(); $data = ['cost_center_id' => $this->f['r']->cost_center_id, 'year' => now()->year, 'amount' => '123.45', 'reason' => 'Decisão fictícia']; $this->postJson('/orcamentos', $data)->assertUnprocessable()->assertJsonValidationErrors('includes_tax'); $this->post('/orcamentos', [...$data, 'includes_tax' => false])->assertRedirect()->assertSessionHasNoErrors(); expect(Budget::query()->firstOrFail()->amount_minor)->toBe(12345) ->and(Budget::query()->firstOrFail()->includes_tax)->toBeFalse(); }); test('failed audit rolls back budget reservation', function () { $this->budget->update(['approved_by' => $this->approver->id]); $this->mock(RequisitionWorkflow::class)->shouldReceive('audit')->andThrow(new RuntimeException('audit unavailable')); expect(fn () => budgetRecord($this, 'reserve', 10000))->toThrow(RuntimeException::class); expect(BudgetEntry::query()->count())->toBe(0); });