Budget — annual budgeting per GL account (domain overview)
The whole budget model reduces to one idea:
A
Budgetis a (year, type) container ofBudgetItemrows, where each item allocates twelve monthly amounts (jan…dec) to one GL account. That's it — the module is pure plan capture. There is no posting, no GL legs, no period close, and (today) no budget-vs-actual / variance computation in the code at all — see §4. A budget is a flat spreadsheet stored relationally: one header + one row per account + twelve columns.
Source: BE src/modules/budget/* (+ budget/item/*) · Admin src/modules/budget/* + src/pages/budget/*
This domain has exactly two sub-modules, both documented here and in budgeting:
- Budget (
budget/*) — the header:{ year, type }, plus itsitems. - Budget Item (
budget/item/*) — the line:{ budgetId, accountId, jan…dec }.
There is no separate "budget period" entity — the period is encoded as the twelve fixed month columns on each item, scoped to the header's year.
1. Entity map
Budget (one per company-branch + year + type; unique on {year,type,branch})
_id, ref, year, type type ∈ { "PROFIT_AND_LOSS", "BALANCE_SHEET" } (free-string column)
│ budgetId (1 ─▶ N)
▼
BudgetItem (one row per account in the budget)
_id, budgetId, accountId,
jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec ← 12 monthly allocations
│ accountId (N ─▶ 1)
▼
Account (a postable GL account — see ../finance/account.md)
accountName, accountNumber, categoryId → AccountCategory (type + report section)
Budget → BudgetItemis referenced (each item carriesbudgetId), not embedded. The GraphQLBudget.itemsis a resolve-field that queriesbudget_itemsbybudgetId(budget.resolver.tsitems()), unless the parent already carries them.BudgetItem → Accountis a referenced FK (accountId,ref: 'Account'). TheBudgetItem.accountGraphQL field is resolved on demand viaAccountService.findById(item.resolver.tsaccount()); failures resolve tonull.- Both collections extend
BaseSchema(so they carrycompanyId,branchId,ref,documentDate, timestamps,canView/canUpdate/canDelete/canPost) and usemongoose-deletesoft-delete (deletedAt,deletedBy) — soft-deleted rows drop out of every query.
| Collection | Source | Represents |
|---|---|---|
budgets |
budget/budget.schema.ts |
one annual budget of a given type |
budget_items |
budget/item/item.schema.ts |
one account's 12-month allocation within a budget |
2. Budget type → which accounts can be budgeted
type is a plain string on the schema, but in practice it is one of two values driven by the admin BUDGET_TYPES list (budget/model.ts):
export const BUDGET_TYPES = [
{ label: 'All', value: '' }, // filter-only
{ label: 'Profit and Loss', value: 'PROFIT_AND_LOSS' },
{ label: 'Balance Sheet', value: 'BALANCE_SHEET' },
];When a budget of a given type is created/edited, the accounts it may contain are not chosen freely — they are resolved from the chart of accounts by BudgetService.getAccountsByBudgetType(type) → GraphQL getAccountsByBudgetType. The mapping from budget type to top-level COA category names lives in finance/finance.model.ts:
export const REPORTING_ACCOUNT_MAPPING = {
PROFIT_AND_LOSS: [
"Sales Revenue", "Sales Adjustments", "Other Income",
"Cost of Sales", "Expenses", "Tax Expense", "Interest Expense",
],
BALANCE_SHEET: [
"Non-Current Assets", "Current Assets",
"Current Liabilities", "Non-Current Liabilities", "Equity",
],
};Resolution (budget.service.ts getAccountsByBudgetType):
- Look up
REPORTING_ACCOUNT_MAPPING[type]→ a list of top-level category names. Unknown type →[]. - For each name,
AccountCategoryService.descendants({ name, parentCompanyId }, includeParent=true)walks the AccountCategory tree to collect that node and all its descendant categories. - De-dup the collected
categoryIds, thenAccountService.find({ parentCompanyId, categoryIds })returns every account under those categories.
So a PROFIT_AND_LOSS budget offers all P&L accounts; a BALANCE_SHEET budget offers all balance-sheet accounts. This is the only link between the budget module and the finance domain.
3. End-to-end flows (cross-module)
3.1 Create / edit a budget (admin → GL accounts → DB)
Admin "Add Budget"
│ pick Year + Budget Type
▼
getAccountsByBudgetType(type) ──▶ BudgetService ──▶ AccountCategory.descendants ──▶ Account.find
│ returns the accounts for that type
▼
Form renders one row per account × 12 month inputs (create.tsx → BudgetItemList)
│ submit
▼
createBudget / updateBudget (CreateBudgetInput { year, type, items:[{accountId, jan…dec}] })
▼
BudgetService.create/update
├─ stamp branchId = ctx.user.branchId
├─ create: reject if a budget with same {year, type, branchId} exists
├─ persist Budget header (auto ref)
└─ createBudgetItems(): upsert one BudgetItem per accountId
(find existing by {budgetId, accountId} → update, else create; null months default to 0)
On update, items are reconciled by wholesale replace: the service first budgetItemSvc.deleteMany({ budgetId }), then re-creates from the payload (see budgeting §4). Each write also re-stamps branchId from context.
3.2 View a budget (totals are client-side)
There is no server-side rollup. Every "total" is computed in the browser:
- List/detail "Total Budget" =
Σ over items of (jan+feb+…+dec)—page.tsx,table.tsx,detail.tsxall repeat the same reduction (getItemTotal). - Monthly summary = per-month column sum across all items (
detail.tsxmonthlyTotals).
The header stores no aggregate; deleting/editing an item self-corrects the displayed total.
3.3 Budget-vs-actual (design intent — NOT implemented)
The conceptual purpose of this module is to compare planned amounts against GL actuals. The machinery to pull actuals already exists in finance (account balanceWithDrAnCrPosted), but no code in either repo wires a budget item to its account's posted balance. A grep for budget ∧ (actual|variance) across zerp-be/src and zerp-admin/src returns nothing outside the unrelated Project module. See budgeting §4 for the exact budget-vs-actual math that would apply and the account aggregation it would call.
4. Shared enums & constants
| Constant | Where | Values |
|---|---|---|
Budget type |
budget/model.ts BUDGET_TYPES (admin) |
PROFIT_AND_LOSS, BALANCE_SHEET (column is free string) |
| Type → COA categories | finance/finance.model.ts REPORTING_ACCOUNT_MAPPING |
see §2 |
| Months | budget/model.ts MONTHS |
Jan…Dec (value 1–12, shotName = the schema column key) |
There is no status/state enum. BaseSchema.canPost exists on the documents but the budget module never posts — there is no posting workflow, no SAVED/POSTED lifecycle (unlike orders or journals).
5. Permissions
Single permission module, four standard actions (zerp-admin/src/constants/UserAccess.ts USER_ACCESS.BUDGET; BE enum permission.enum.ts BUDGET = "budget"):
| Module | value | actions |
|---|---|---|
BUDGET |
budget |
view, create, update, delete |
- Both admin pages (
pages/budget/index.tsx,pages/budget/[_id].tsx) gate server-side onbudget/viewviaApGuardBuilder.haveAccess. - Every BE mutation carries
@AuditMeta(modulebudget/budget-item, collectionbudgets/budget_items) and all resolvers are@ApGqlAuthorize(). Full RBAC: ../../platform/permissions-access.md.
6. Gotchas
- No variance / actuals. The module captures plan only. Anyone expecting budget-vs-actual must build it (budgeting §4).
typeis a free string, not an enum — only the admin UI constrains it to the two values, andREPORTING_ACCOUNT_MAPPINGsilently returns[]for any other value.- Uniqueness is
{year, type, branchId}and enforced in the service (createonly), not by a DB index. Updates do not re-check uniqueness. - All 12 month props are
requiredin Mongoose — the service/create-flow default missing months to0before insert, so the DB never sees a null month. - Branch-scoped, not company-scoped at write time. Both schemas stamp
branchId = ctx.user.branchIdon every create; there is no explicitcompanyIdset in the service (it comes fromBaseSchemadefaults / repository context).