Budgeting — Budget header + per-account monthly allocations

The whole budgeting module reduces to one idea:

A budget is a Budget header { year, type } plus N BudgetItem rows, each row pinning one GL account to twelve monthly amounts (jan…dec). It is plan capture only — relationally-stored spreadsheet cells. The module never posts to the ledger, computes no totals server-side, and ships no budget-vs-actual comparison (§4.4). Actuals, if ever wired, come from the finance GL via the account-balance aggregation, not from anything in this module.

Source: BE src/modules/budget/*, src/modules/budget/item/* · Admin src/modules/budget/* + src/pages/budget/*


1. Purpose & scope

The budget module owns:

  • Create / update / delete annual budgets, each scoped to a (year, type) and stamped with the user's branchId.
  • For each budget, store one BudgetItem per account with twelve monthly allocation columns.
  • Resolve which accounts a budget of a given type may contain, by walking the chart-of-accounts category tree (getAccountsByBudgetType).
  • Page/find budgets and budget items.

It explicitly does NOT:

  • Post to the general ledger or create any AccountTransaction legs — budgets carry no GL impact (contrast transaction).
  • Compute actuals or variance. There is no budget-vs-actual query, resolve-field, or report in the code (§4.4 — design intent only).
  • Have any approval / posting lifecycle (no SAVED/POSTED, no canPost usage).
  • Store any rollup/total — every total is recomputed client-side (§7).

2. Data model

budgetsBudget (budget/budget.schema.ts)

One row = one annual budget of a given type, for one branch.

field type required? description
ref string yes generated document reference; unique. From BaseSchema.generateRef().
year number yes the budget year (e.g. 2026).
type string yes budget type — in practice "PROFIT_AND_LOSS" or "BALANCE_SHEET" (free string at schema level; constrained only by the admin BUDGET_TYPES list).

Plus all BaseSchema fields: _id, key, companyId, branchId, documentCode, documentDate, createdAt/createdBy, updatedAt/updatedBy, canView/canUpdate/canDelete/canPost. Soft-delete via mongoose-delete (deletedAt, deletedBy).

@ApSchema({ collection: "budgets" })
export class Budget extends BaseSchema {
  @Prop({ unique: true, required: true }) ref: string;
  @Prop({ required: true })               year: number;
  @Prop({ required: true })               type: string;
}

Budget.items is not a stored field. It is a GraphQL resolve-field (budget.dto.ts items: [BudgetItem], resolved in budget.resolver.ts).

budget_itemsBudgetItem (budget/item/item.schema.ts)

One row = one account's 12-month allocation within a budget.

field type required? description
budgetId ObjectId yes FK → Budget (ref: 'Budget').
accountId ObjectId yes FK → Account (ref: 'Account') — the budgeted GL account.
jandec number ×12 yes (all) the monthly allocation for each calendar month.
@ApSchema({ collection: "budget_items" })
export class BudgetItem extends BaseSchema {
  @Prop({ type: ObjectId, ref: 'Budget',  required: true }) budgetId: string;
  @Prop({ type: ObjectId, ref: 'Account', required: true }) accountId: string;
  @Prop({ required: true }) jan: number;  @Prop({ required: true }) feb: number;
  @Prop({ required: true }) mar: number;  @Prop({ required: true }) apr: number;
  @Prop({ required: true }) may: number;  @Prop({ required: true }) jun: number;
  @Prop({ required: true }) jul: number;  @Prop({ required: true }) aug: number;
  @Prop({ required: true }) sep: number;  @Prop({ required: true }) oct: number;
  @Prop({ required: true }) nov: number;  @Prop({ required: true }) dec: number;
}

Plus the same BaseSchema fields + soft-delete as Budget.

Period allocation model: the budget "period" is not a separate entity. Each item is twelve fixed columns; the year comes from the parent Budget.year. There is no quarter/week granularity and no arbitrary date ranges — the grain is hard-coded to calendar months. The annual figure for an account is Σ(jan…dec) (computed client-side; §7).

Enums: none on the schema. The admin layer supplies two list constants (budget/model.ts):

export const BUDGET_TYPES = [
  { label: 'All', value: '' },
  { label: 'Profit and Loss', value: 'PROFIT_AND_LOSS' },
  { label: 'Balance Sheet', value: 'BALANCE_SHEET' },
];

export const MONTHS = [
  { label: 'January', value: 1, shotName: 'Jan' },, { label: 'December', value: 12, shotName: 'Dec' },
];

MONTHS[i].shotName.toLowerCase() is exactly the schema column key (jandec) — the UI maps between the picklist and the columns through this shotName.

Relationships / scoping: both collections are referenced (no embedding) and branch-scoped (branchId stamped from context on create). BudgetItem has no compound DB index; lookups are by {budgetId} or {budgetId, accountId}.


3. API surface

All resolvers @ApGqlAuthorize(), extending ApBaseResolver<T>. No REST controllers (N/A — the module is GraphQL-only).

Budget (budget.resolver.ts)

Operation Type Input Returns Permission (audit)
findOneBudget query BudgetQueryInput { _id?, year?, type? } Budget (nullable) @ApGqlAuthorize
budgetPage query BudgetPageInput { skip, take, keyword?, year?, type? } BudgetPageResult { totalRecords, data:[Budget] } @ApGqlAuthorize
getAccountsByBudgetType query GetAccountsByBudgetTypeInput { budgetType } [Account] @ApGqlAuthorize
createBudget mutation CreateBudgetInput (arg budget) Budget audit budget/budgets/CREATE
updateBudget mutation id, UpdateBudgetInput (budget) Budget audit UPDATE
deleteBudget mutation id Boolean audit DELETE

CreateBudgetInput (= BudgetCommonInput): { year!, type!, items: [BudgetItemCreateInput] }. UpdateBudgetInput = PartialType(BudgetCommonInput). BudgetItemCreateInput: { accountId!, jan?…dec? } (months nullable, default 0 on the server).

Resolve-field Budget.items: returns budget.items if already populated on the parent, else budgetItemSvc.find({ budgetId: budget._id }).

Budget Item (budget/item/item.resolver.ts)

Operation Type Input Returns Permission (audit)
findOneBudgetItem query BudgetItemQueryInput { _id?, budgetId?, accountId? } BudgetItem @ApGqlAuthorize
budgetItemPage query BudgetItemPageInput { skip, take, keyword?, budgetId?, accountId?, month? } BudgetItemPageResult @ApGqlAuthorize
createBudgetItem mutation CreateBudgetItemInput (budgetItem) BudgetItem audit budget-item/budget_items/CREATE
updateBudgetItem mutation id, UpdateBudgetItemInput BudgetItem audit UPDATE
deleteBudgetItem mutation id Boolean audit DELETE

CreateBudgetItemInput (= BudgetItemCommonInput): { budgetId?, accountId!, jan!…dec! } (all months required here — distinct from the nullable BudgetItemCreateInput used inside a budget). UpdateBudgetItemInput = PartialType(BudgetItemCommonInput).

Note: BudgetItemPageInput.month is accepted but the repository buildQuery only matches schema keys (schemaKeysQuery); month is not a schema field, so it is effectively ignored.

Resolve-field BudgetItem.account: returns item.account if present, else accountSvc.findById(item.accountId) (returns null on error).

Pagination

Both page() methods use the local handlePageFacet/handlePageResult aggregation helpers ($facetdata: [$skip, $limit] + totalRecords: [$count]). buildQuery is generic schemaKeysQuery over the schema fields — so budgetPage filters by year/type, and budgetItemPage filters by budgetId/accountId.


4. Business rules & calculations

4.1 Create (BudgetService.create)

model.branchId = ctx.user.branchId;
const existing = budgetRepo.findOne({ year, type, branchId });
if (existing) throw `A budget for ${year} - ${type} already exists`;   // uniqueness guard
const budget = budgetRepo.create(model);                                // auto ref
if (model.items?.length) await createBudgetItems(budget._id, model.items);
  • Uniqueness is enforced only here, on {year, type, branchId} (service-level, not a DB index).
  • createBudgetItems upserts per account: findOne({budgetId, accountId}) → update if found, else create; every month coerced via item.jan || 0 (null/undefined/0 → 0).

4.2 Update (BudgetService.update)

existing = budgetRepo.findById(id); if (!existing) throw "Budget not found";
if (model.items.length && model.items !== undefined) {
  await budgetItemSvc.deleteMany({ budgetId: id });   // wholesale replace
  if (model.items.length > 0) await createBudgetItems(id, model.items);
}
const { items, ...budgetUpdate } = model;
return budgetRepo.update(id, budgetUpdate);            // header fields only
  • Item reconciliation is delete-all-then-recreate (soft-delete the old rows, insert the new set). It does not diff. The upsert inside createBudgetItems is therefore redundant on update (the prior rows were just soft-deleted) but harmless.
  • Uniqueness is not re-checked on update — you can edit year/type into a clash.

4.3 Validation

There are no class-validator decorators on the inputs beyond GraphQL non-null. Service-level invariants are: the uniqueness guard (create), "Budget not found" (update). All twelve month props are required: true in Mongoose, but the create path defaults them to 0 before insert so an omitted month never violates the constraint. Admin Yup adds year required, type required, account._id required, and min(0) on each month (create.tsx).

4.4 Budget-vs-actual & variance — design intent (NOT in code)

There is no budget-vs-actual or variance computation anywhere in zerp-be or zerp-admin. A repo-wide grep for budget ∧ (actual|variance) returns only the unrelated Project module (Project.budgetVariance/estimatedBudget — a different feature). This section documents the math that would apply, so a rebuild can wire it; it is not implemented today.

The budgeted figure for an account/period is already in budget_items:

budgetedMonth(acct, m)  = budget_item.<m>                          // e.g. .mar
budgetedYTD(acct, toM)  = Σ budget_item.<jan..toM>
budgetedAnnual(acct)    = Σ budget_item.<jan..dec>

The actual for the same account/period is not stored — it is the GL posted balance over that date range, which finance already exposes via account balanceWithDrAnCrPosted(accountId, { fromDate, toDate }). That aggregation sums posted AccountTransaction legs and maps to a signed balance by the account's category type (see finance overview §1.1):

actual(acct, [from,to]) = mapBalance(type, totalDrAnCrPosted({ accountId, fromDate, toDate }))
  where mapBalance(type,{debits,credits}) =
    type.credit === "INCREASE" ? credits − debits   // INCOME / LIABILITY / EQUITY
                               : debits − credits    // EXPENSE / ASSET / INVENTORY

Budget-vs-actual would then be, per account and period:

variance        = actual − budgeted
variancePct     = budgeted === 0 ? null : (variance / budgeted) × 100
favourable?     = for INCOME accounts   variance > 0  (beat target)
                  for EXPENSE accounts  variance < 0  (under-spent)

To build it: for each BudgetItem, derive [fromDate, toDate] from budget.year + the month column, call account.balanceWithDrAnCrPosted, and subtract. Roll up by COA category for a P&L/balance-sheet budget report. None of this exists yet — it is the single largest gap in the module.

4.5 State machine / side effects / transactionality

  • State machine: none. Budgets have no status. canPost is inherited but unused.
  • Side effects: none beyond writing budgets/budget_items and the audit-trail entry from @AuditMeta. No GL legs, no stock, no notifications.
  • Transactionality: the budget services extend AbstractBaseService (with a TransactionManager injected) but create/update here run their repo calls without an explicit withRetryTransaction wrapper — header insert, item delete, and item inserts are separate operations. (Contrast the order/stock flows in inventory, which are transacted.) A partial failure mid-update can leave items deleted without their replacements. Flagged as a gotcha (§9).

5. Permissions

Module value actions
BUDGET budget view, create, update, delete

(zerp-admin/src/constants/UserAccess.ts USER_ACCESS.BUDGET; BE permission/permission.enum.ts BUDGET = "budget".) Both budget pages guard server-side on budget/view. All mutations are audited (@AuditMeta, modules budget / budget-item). No CASL abilities or row-level scoping specific to budget beyond the standard branch/company context. Full RBAC: ../../platform/permissions-access.md.


6. Flows

6.1 Create a budget (admin → DB)

  1. Admin /budgetAdd Budget (gated budget/view to reach the page) opens the CreateBudgetEntry modal (components/create.tsx).
  2. User picks Year and Budget Type. On type change, the form calls fetchAccountsByBudgetType(type)getAccountsByBudgetType query → BudgetService.getAccountsByBudgetType: REPORTING_ACCOUNT_MAPPING[type]AccountCategory.descendants(name, includeParent) per category name → de-dup categoryIdsAccountService.find({ categoryIds }).
  3. The form renders one row per returned account × 12 month inputs (BudgetItemList), all defaulted to 0. A live BudgetSummary shows Σ all months client-side.
  4. On submit, the payload maps each row to { accountId, jan…dec } (months coerced +x || 0, rows without account._id dropped) → createBudget (or updateBudget if a budget already exists for that year+type, detected via findOneBudget in handleFilterChange).
  5. Resolver createBudgetBudgetService.create → uniqueness guard → persist header → createBudgetItems (upsert per account).
  6. On success the context toasts and re-runs fetchBudgetPage(filter).

Unhappy paths: duplicate {year,type,branch}A budget for … already exists; empty items → submit button disabled + toast "Please select a Budget Type to load accounts"; invalid form → toast "Please fill in all required fields".

6.2 Edit a budget

Same modal, pre-loaded from findOneBudget. Submit → updateBudgetBudgetService.update: delete-all items by budgetId, recreate from payload, update header fields. The detail page (/budget/[_id]) and the list both expose Edit only when budget.canUpdate.

6.3 Delete a budget

deleteBudget(id)deleteBudget mutation → BudgetService.delete (base soft-delete). Exposed only when budget.canDelete. From the detail page, success routes back to /budget. Budget items are not explicitly cascaded on budget delete (only update deletes items); orphan budget_items would remain soft-linked by budgetId — flagged §9.

6.4 View a budget

/budget/[_id] server-side-fetches the budget via findOneBudgetAsync (with the items resolve- field) and seeds context. BudgetDetailPage shows year/type/total-items/total-budget and a table of items with all 12 months + per-row total, plus a client-side monthly summary. No actuals column (§4.4).


7. Admin UI

  • Routes: src/pages/budget/index.tsx (list, guarded budget/view), src/pages/budget/[_id].tsx (detail, guarded budget/view, SSR-loads the budget).
    • The list "view detail" button links to two different targets in different components: page.tsx/budget/${_id} (the implemented detail page); components/table.tsx/budget/entry?year=…&type=… (an entry route that does not exist under src/pages/budget/ — a dead link / leftover). page.tsx renders its own inline table, so the standalone BudgetTable component appears unused.
  • Module: src/modules/budget/page.tsx (BudgetPage), detail.tsx (BudgetDetailPage), context.tsx (BudgetContextProvider / useBudgetState), gql/{query,fragment}.ts, model.ts, components/{create,table}.tsx.
  • Context methods (useBudgetState): fetchBudgetPage, createBudget, updateBudget, deleteBudget, findOneBudget, fetchBudgetItemPage, createBudgetItem, updateBudgetItem, deleteBudgetItem, findOneBudgetItem, fetchAccountsByBudgetType; state: budgets, budget, budgetItems, budgetItem, totalRecords, totalItemRecords, loading, modal. After create/update the context re-runs fetchBudgetPage; delete filters the row out locally.
  • Forms: Formik + Yup (FormSchema in create.tsx): year & type required, each month min(0), account._id required per row. The account rows are generated from getAccountsByBudgetType — accounts are not free-typed; the user only fills month amounts.
  • Tables: list columns Year / Type / Items (count) / Total Budget (Σ all months, helper.toCurrency) / actions (delete if canDelete, edit if canUpdate, view-detail). Detail table: Account / Account Number / 12 month columns / per-row Total, plus a client-side Monthly Summary card (per-month column sums, omitting zero months).
  • UX: Year + Budget-Type filters on the list (both ignoreFormik); the create modal width 80%; every total is recomputed in the browser — no server rollup.

8. Dependencies & integrations

  • account (AccountService) — find({ categoryIds }) for type→accounts resolution; findById for the BudgetItem.account resolve-field. (Also the source of GL actuals if budget-vs-actual is ever built — §4.4.)
  • category (AccountCategoryService.descendants) — walks the COA tree to collect the categories under each REPORTING_ACCOUNT_MAPPING name.
  • finance/finance.model.tsREPORTING_ACCOUNT_MAPPING (type → category names), ACCOUNT_NAME (the category-name strings).
  • AuthModule@ApGqlAuthorize; audit-trail via @AuditMeta.
  • Admin BudgetPage also pulls useAccountState().accountPage({pageSize:10000}) on mount (to warm account data), but the budget rows themselves come from getAccountsByBudgetType.
  • No events, no cron, no external services. Module dependencies are all wired with forwardRef (circular import safety with the finance/account modules).

9. Gotchas & project-specific rules

  • No budget-vs-actual / variance in code (§4.4) — the headline gap. The whole reason to budget, but the comparison query/report does not exist. Build it on account.balanceWithDrAnCrPosted.
  • No server-side totals. Every "Total Budget" / monthly summary is a client-side reduction, repeated in page.tsx, table.tsx, and detail.tsx. A rebuild should centralize this.
  • type is a free string, not an enum; only the admin constrains it, and REPORTING_ACCOUNT_MAPPING returns [] for any unmapped value (so the form would render zero rows).
  • Update is delete-all-then-recreate, untransacted (§4.4) — a failure between deleteMany({budgetId}) and re-insert can leave a budget with no items. No withRetryTransaction wrapper despite a TransactionManager being injected.
  • Delete does not cascade items. Deleting a budget soft-deletes only the header; its budget_items are not explicitly removed (only the update path deletes items).
  • BudgetItemPageInput.month is inert — accepted by the input but not a schema field, so buildQuery ignores it.
  • Two inconsistent BudgetItem create inputs: BudgetItemCreateInput (months nullable, used inside a budget) vs CreateBudgetItemInput (months required, used by the standalone createBudgetItem mutation). Same collection, different contracts.
  • Uniqueness only on create, only at service level — editing year/type can produce a duplicate {year,type,branch}.
  • Branch-scoped writesbranchId is re-stamped from ctx.user.branchId on every budget and item create; there is no explicit companyId set in the service.
  • Dead entry routecomponents/table.tsx links to /budget/entry?…, which has no page file; the rendered list (page.tsx) links to /budget/${_id} instead.