Budgeting — Budget header + per-account monthly allocations
The whole budgeting module reduces to one idea:
A budget is a
Budgetheader{ year, type }plus NBudgetItemrows, 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'sbranchId. - For each budget, store one
BudgetItemper 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
AccountTransactionlegs — 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, nocanPostusage). - Store any rollup/total — every total is recomputed client-side (§7).
2. Data model
budgets — Budget (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.itemsis not a stored field. It is a GraphQL resolve-field (budget.dto.tsitems: [BudgetItem], resolved inbudget.resolver.ts).
budget_items — BudgetItem (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. |
jan…dec |
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 (jan…dec) — 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.monthis accepted but the repositorybuildQueryonly matches schema keys (schemaKeysQuery);monthis 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 ($facet → data: [$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). createBudgetItemsupserts per account:findOne({budgetId, accountId})→ update if found, else create; every month coerced viaitem.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
createBudgetItemsis therefore redundant on update (the prior rows were just soft-deleted) but harmless. - Uniqueness is not re-checked on update — you can edit
year/typeinto 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.
canPostis inherited but unused. - Side effects: none beyond writing
budgets/budget_itemsand the audit-trail entry from@AuditMeta. No GL legs, no stock, no notifications. - Transactionality: the budget services extend
AbstractBaseService(with aTransactionManagerinjected) butcreate/updatehere run their repo calls without an explicitwithRetryTransactionwrapper — header insert, item delete, and item inserts are separate operations. (Contrast the order/stock flows in inventory, which are transacted.) A partial failure mid-updatecan 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)
- Admin
/budget→Add Budget(gatedbudget/viewto reach the page) opens theCreateBudgetEntrymodal (components/create.tsx). - User picks Year and Budget Type. On type change, the form calls
fetchAccountsByBudgetType(type)→getAccountsByBudgetTypequery →BudgetService.getAccountsByBudgetType:REPORTING_ACCOUNT_MAPPING[type]→AccountCategory.descendants(name, includeParent)per category name → de-dupcategoryIds→AccountService.find({ categoryIds }). - The form renders one row per returned account × 12 month inputs (
BudgetItemList), all defaulted to0. A liveBudgetSummaryshowsΣ all monthsclient-side. - On submit, the payload maps each row to
{ accountId, jan…dec }(months coerced+x || 0, rows withoutaccount._iddropped) →createBudget(orupdateBudgetif a budget already exists for thatyear+type, detected viafindOneBudgetinhandleFilterChange). - Resolver
createBudget→BudgetService.create→ uniqueness guard → persist header →createBudgetItems(upsert per account). - 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 → updateBudget → BudgetService.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, guardedbudget/view),src/pages/budget/[_id].tsx(detail, guardedbudget/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=…(anentryroute that does not exist undersrc/pages/budget/— a dead link / leftover).page.tsxrenders its own inline table, so the standaloneBudgetTablecomponent appears unused.
- The list "view detail" button links to two different targets in different components:
- 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. Aftercreate/updatethe context re-runsfetchBudgetPage;deletefilters the row out locally. - Forms: Formik + Yup (
FormSchemaincreate.tsx):year&typerequired, each monthmin(0),account._idrequired per row. The account rows are generated fromgetAccountsByBudgetType— 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 ifcanDelete, edit ifcanUpdate, 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;findByIdfor theBudgetItem.accountresolve-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 eachREPORTING_ACCOUNT_MAPPINGname. finance/finance.model.ts—REPORTING_ACCOUNT_MAPPING(type → category names),ACCOUNT_NAME(the category-name strings).- AuthModule —
@ApGqlAuthorize; audit-trail via@AuditMeta. - Admin
BudgetPagealso pullsuseAccountState().accountPage({pageSize:10000})on mount (to warm account data), but the budget rows themselves come fromgetAccountsByBudgetType. - 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, anddetail.tsx. A rebuild should centralize this. typeis a free string, not an enum; only the admin constrains it, andREPORTING_ACCOUNT_MAPPINGreturns[]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. NowithRetryTransactionwrapper despite aTransactionManagerbeing injected. - Delete does not cascade items. Deleting a budget soft-deletes only the header; its
budget_itemsare not explicitly removed (only the update path deletes items). BudgetItemPageInput.monthis inert — accepted by the input but not a schema field, sobuildQueryignores it.- Two inconsistent
BudgetItemcreate inputs:BudgetItemCreateInput(months nullable, used inside a budget) vsCreateBudgetItemInput(months required, used by the standalonecreateBudgetItemmutation). Same collection, different contracts. - Uniqueness only on create, only at service level — editing
year/typecan produce a duplicate{year,type,branch}. - Branch-scoped writes —
branchIdis re-stamped fromctx.user.branchIdon every budget and item create; there is no explicitcompanyIdset in the service. - Dead
entryroute —components/table.tsxlinks to/budget/entry?…, which has no page file; the rendered list (page.tsx) links to/budget/${_id}instead.