Budget — annual budgeting per GL account (domain overview)

The whole budget model reduces to one idea:

A Budget is a (year, type) container of BudgetItem rows, 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 its items.
  • 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 → BudgetItem is referenced (each item carries budgetId), not embedded. The GraphQL Budget.items is a resolve-field that queries budget_items by budgetId (budget.resolver.ts items()), unless the parent already carries them.
  • BudgetItem → Account is a referenced FK (accountId, ref: 'Account'). The BudgetItem.account GraphQL field is resolved on demand via AccountService.findById (item.resolver.ts account()); failures resolve to null.
  • Both collections extend BaseSchema (so they carry companyId, branchId, ref, documentDate, timestamps, canView/canUpdate/canDelete/canPost) and use mongoose-delete soft-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):

  1. Look up REPORTING_ACCOUNT_MAPPING[type] → a list of top-level category names. Unknown type → [].
  2. For each name, AccountCategoryService.descendants({ name, parentCompanyId }, includeParent=true) walks the AccountCategory tree to collect that node and all its descendant categories.
  3. De-dup the collected categoryIds, then AccountService.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.tsx all repeat the same reduction (getItemTotal).
  • Monthly summary = per-month column sum across all items (detail.tsx monthlyTotals).

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 on budget/view via ApGuardBuilder.haveAccess.
  • Every BE mutation carries @AuditMeta (module budget / budget-item, collection budgets / 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).
  • type is a free string, not an enum — only the admin UI constrains it to the two values, and REPORTING_ACCOUNT_MAPPING silently returns [] for any other value.
  • Uniqueness is {year, type, branchId} and enforced in the service (create only), not by a DB index. Updates do not re-check uniqueness.
  • All 12 month props are required in Mongoose — the service/create-flow default missing months to 0 before insert, so the DB never sees a null month.
  • Branch-scoped, not company-scoped at write time. Both schemas stamp branchId = ctx.user.branchId on every create; there is no explicit companyId set in the service (it comes from BaseSchema defaults / repository context).