Budget module expertise for Ogami ERP. Use when creating cost centers, setting annual budget lines, running utilisation analysis, debugging approval workflow issues, or integrating budget checks into Procurement/AP. Covers the Budget domain service, SoD constraints, and GL-linked utilisation queries.
Domain knowledge for the Budget module (app/Domains/Budget/), including cost center hierarchy, annual budget lines, approval workflow, and utilisation analysis.
hasAvailableBudget() into Procurement or AP spend guardsBudgetController actions or BudgetService methodsCostCenter — organisational budget unit; self-referential hierarchy
└── AnnualBudget — one line per (cost_center, fiscal_year, account)
: : : (prefix ) : :
app/Domains/Budget/Services/BudgetService.phpapp/Http/Controllers/Budget/BudgetController.phproutes/api/v1/budget.php/api/v1/budgetfrontend/src/hooks/useBudget.tsfrontend/src/types/budget.tscode is always stored uppercase — the service calls strtoupper(). Never rely on the client to uppercase it.parent_id creates a hierarchy (e.g., Plant → Line → Cell); self-referential FK on cost_centers.id.department_id is optional; sets the owning department without limiting access.restrictOnDelete FK).$service->storeCostCenter(array $data, User $actor): CostCenter
// $data: name*, code*, description?, department_id?, parent_id?, is_active?
$service->updateCostCenter(CostCenter $cc, array $data, User $actor): CostCenter
// All fields optional (sometimes). Handles explicit null for department_id and parent_id.
(cost_center_id, fiscal_year, account_id) — only one budget line per account per year per cost center. setBudgetLine() performs an upsert (firstOrNew), so calling it a second time updates the amount rather than creating a duplicate.
draft ──> submitted ──> approved
^ │
└───────────┘ (rejected → can resubmit)
approved_by_id <> submitted_by_id — a database CHECK constraint prevents the same user from both submitting and approving. The service additionally throws:
throw new DomainException('SOD_VIOLATION', 'SOD_VIOLATION', 403);
$service->setBudgetLine(array $data, User $actor): AnnualBudget
// $data: cost_center_id*, fiscal_year*, account_id*, budgeted_amount_centavos*, notes?
// UPSERT: updates existing line if (cc, year, account) tuple already exists
$service->submitBudget(AnnualBudget $budget, User $actor): AnnualBudget
// Only 'draft' or 'rejected' → 'submitted'. Throws BUDGET_INVALID_STATUS (422) otherwise.
// Clears all approval fields on resubmission.
$service->approveBudget(AnnualBudget $budget, User $actor, ?string $remarks): AnnualBudget
// Only 'submitted' → 'approved'. Throws SOD_VIOLATION (403) if actor === submitter.
$service->rejectBudget(AnnualBudget $budget, User $actor, ?string $remarks): AnnualBudget
// Only 'submitted' → 'rejected'.
getUtilisation(CostCenter $cc, int $fiscalYear): array returns:
[
'cost_center' => [...],
'fiscal_year' => 2026,
'lines' => [
[
'budget_ulid' => '...',
'account_id' => 42,
'account_code' => '5-1001',
'account_name' => 'Raw Materials',
'normal_balance' => 'DEBIT',
'budgeted_amount_centavos' => 5_000_000,
'actual_amount_centavos' => 3_200_000,
'variance_centavos' => 1_800_000, // positive = under budget
'utilisation_pct' => 64.0, // >100 = over budget
],
// ...
],
]
How actuals are computed:
journal_entry_lines for entries where posted_at year matches.account.normal_balance (DEBIT/CREDIT) to determine sign.× 100) for comparison.Debugging zero actuals:
posted status and posted_at is in the correct year.journal_entry_lines.cost_center_id is set — only lines with a cost center are included.journal_entry_lines.cost_center_id is now a bigint FK (migration 000011 widened it).hasAvailableBudget() is called before approving purchase requests:
$service->hasAvailableBudget(
costCenterId: $pr->cost_center_id,
accountId: $pr->account_id,
fiscalYear: now()->year,
requestedCentavos: $pr->total_amount_centavos
): bool
true if no budget line exists (no ceiling enforced).false if (current_spend + requested) > budgeted.| Permission | Who holds it |
|---|---|
budget.view | All roles with budget access |
budget.manage | admin, executive, vice_president, manager |
budget.approve | admin, executive, vice_president |
The approve and reject routes use middleware('permission:budget.approve') directly — there is no approve() method in BudgetPolicy.
| Method | URI | Action | Middleware |
|---|---|---|---|
| GET | /api/v1/budget/cost-centers | indexCostCenters | budget.view |
| POST | /api/v1/budget/cost-centers | storeCostCenter | budget.manage |
| PATCH | /api/v1/budget/cost-centers/{costCenter} | updateCostCenter | budget.manage |
| GET | /api/v1/budget/lines | indexBudgets | budget.view — requires cost_center_id + fiscal_year |
| POST | /api/v1/budget/lines | setBudgetLine | budget.manage |
| GET | /api/v1/budget/utilisation/{costCenter} | utilisation | budget.view — requires fiscal_year query param |
| PATCH | /api/v1/budget/lines/{annualBudget}/submit | submitBudget | budget.manage |
| PATCH | /api/v1/budget/lines/{annualBudget}/approve | approveBudget | budget.approve |
| PATCH | /api/v1/budget/lines/{annualBudget}/reject | rejectBudget | budget.approve |
Route parameters {costCenter} and {annualBudget} resolve via ULID (both models use HasPublicUlid).
frontend/src/types/budget.ts → AnnualBudget does not include status, submitted_by_id, approved_by_id, or approval timestamps. Those fields are only in the inline type inside useBudget.ts. When extending frontend types, update both locations.
beforeEach(function () {
$this->artisan('db:seed', ['--class' => 'RolePermissionSeeder'])->assertExitCode(0);
$this->artisan('db:seed', ['--class' => 'ChartOfAccountsSeeder'])->assertExitCode(0);
$this->artisan('db:seed', ['--class' => 'FiscalPeriodSeeder'])->assertExitCode(0);
$this->manager = User::factory()->create();
$this->manager->assignRole('manager');
$this->executive = User::factory()->create();
$this->executive->assignRole('executive');
});
Key notes for Budget tests:
setBudgetLine is an upsert — calling it twice with the same (cc, year, account) updates, not duplicates.cost_center_id set on the lines.