Fiscal Periods — the posting calendar & period lock
The whole module reduces to one idea: a company's financial year is sliced into monthly
FiscalPeriodrows, and a transaction may only post if its document date falls inside anOPENperiod. Locking or closing a period turns it into a wall that blocks any new/posting transaction dated within it — that is how zerp prevents back-dated edits to settled months.
Source: BE src/modules/fiscal · Admin src/modules/fiscalPeriod
1. Purpose & scope
- Maintain, per company, one
FiscalPeriodrow per calendar month of each fiscal year. - Provide the date-lock check every posting service calls (
validateTransactionDate) so journals, payments, cashbook, notes, transactions, asset events, orders/returns cannot be dated into aLOCKED/CLOSEDmonth. - Period lifecycle: OPEN → LOCKED ⇄ OPEN, and OPEN/LOCKED → CLOSED (terminal).
- Auto-generate next year's periods (
createNextFiscalYear).
Does NOT do: posting itself, GL legs, or year-end roll-up/closing entries. It only gates whether a date is postable. The actual posting and GL writes live in finance — see journal.md. It also does not enforce branch scope (periods are company-scoped only).
2. Data model
fiscal_periods — fiscal/fiscal.schema.ts
| field | type | req? | description |
|---|---|---|---|
_id |
ObjectId | — | id |
name |
string | ✅ | human label, e.g. "March 2026" (monthStart.format("MMMM YYYY")) |
month |
number | ✅ | 1–12 (calendar month, monthStart.month()+1) |
year |
number | ✅ | calendar year |
startDate |
number (unix ms) | ✅ | UTC-anchored start of the period (month start, or fiscal-year start in the first month) |
endDate |
number (unix ms) | ✅ | UTC-anchored end of the period (month end, or fiscal-year end in the last month) |
status |
enum FiscalPeriodStatus |
default OPEN |
OPEN | LOCKED | CLOSED |
companyId |
ObjectId → Company |
✅ | owning company (tenant scope key) |
lockedBy |
ObjectId → User |
— | who locked/closed it |
lockedAt |
number | — | when locked/closed |
lockReason |
string | — | optional reason (passed to lockFiscalPeriod) |
createdAt / updatedAt |
Date | — | timestamps (timestamps:true) |
Soft-deleted via mongoose-delete (deletedAt, deletedBy).
Indexes:
FiscalPeriodSchema.index({ companyId: 1, year: 1, month: 1 }, { unique: true }); // one row per company+month+year
FiscalPeriodSchema.index({ companyId: 1, status: 1 });
FiscalPeriodSchema.index({ startDate: 1, endDate: 1 });// fiscal/fiscal.schema.ts
export enum FiscalPeriodStatus {
OPEN = "OPEN",
LOCKED = "LOCKED",
CLOSED = "CLOSED"
}Note:
FiscalPerioddoes not extendBaseSchema(it is a plain@ApSchemaclass), so it has noref/branchId/canPostfields. It carriescompanyIdexplicitly and is queried by it directly — it is not auto-scoped by the base repository'scompanyMatchthe wayBaseSchemacollections are. The GraphQLIFiscalPeriodmodel also surfaces UI-onlycanLock/canUnlock/lockReason.
3. API surface
All under @ApGqlAuthorize({}) in fiscal/fiscal.resolver.ts.
| Operation | Type | Input | Returns | Audit |
|---|---|---|---|---|
fiscalPeriodPage |
Query | FiscalPeriodPageInput {skip,take,keyword?,sortBy?,sortOrder?} |
FiscalPeriodPageResult |
— |
fiscalPeriods |
Query | companyId?: ID |
[FiscalPeriod] (sorted year↑, month↑) |
— |
fiscalPeriod |
Query | id: ID |
FiscalPeriod |
— |
updateFiscalPeriod |
Mutation | UpdateFiscalPeriodInput {id, name?, month?, year?, startDate?, endDate?, status?} |
FiscalPeriod |
UPDATE |
generateFiscalPeriods |
Mutation | GenerateFiscalPeriodsInput {fiscalYearStartDate, fiscalYearEndDate, companyId} |
[FiscalPeriod] (computed, not persisted) |
CREATE |
seedFiscalPeriods |
Mutation | GenerateFiscalPeriodsInput |
Boolean (upserts rows) |
STATUS_CHANGE |
lockFiscalPeriod |
Mutation | id: ID, reason?: String |
FiscalPeriod |
STATUS_CHANGE |
unlockFiscalPeriod |
Mutation | id: ID |
FiscalPeriod |
STATUS_CHANGE |
closeFiscalPeriod |
Mutation | id: ID |
FiscalPeriod |
STATUS_CHANGE |
createNextFiscalYear |
Mutation | CreateNextFiscalYearInput {companyId?} |
[FiscalPeriod] |
CREATE |
generateFiscalPeriodsreturns the computed list without saving (preview);seedFiscalPeriodspersists them (upsert by month+year). Company create/update callsseedFiscalPeriodsinternally — see §6.
4. Business rules & calculations
4.1 Period generation — generatePeriodsFromFiscalYear()
Splits a fiscal year [start, end] into one period per month. All math is UTC-anchored (dayjs.utc) — deliberately, so a date-only boundary like "1st of month" is not shifted into the previous day for non-UTC tenants.
for each month from start.startOf('month') to end.startOf('month'):
monthStart = month start
monthEnd = month end (23:59:59.999)
if first month: monthStart = startDayjs.date() (respect fiscal-year start day)
if last month: monthEnd = endDayjs.date() @ 23:59:59.999 (respect fiscal-year end day)
push { name: "MMMM YYYY", month: m+1, year, startDate, endDate, status: OPEN, companyId }
Throws 400 if start is after end.
4.2 Seeding (upsert) — seedFiscalPeriods()
For each generated period: findByMonthYear(month, year, companyId). If absent → create; if present → update only {startDate, endDate, name} (status/lock preserved). Idempotent — safe to re-run on fiscal-year change.
4.3 The lock check — isDateLocked() / validateTransactionDate() (the core rule)
// fiscal/fiscal.service.ts → isDateLocked(date, companyId)
const period = await repo.findByDate(date, companyId); // startDate <= date <= endDate, not deleted
if (period) {
if (status === LOCKED || status === CLOSED)
return { locked:true, period, message:`This period (${name}) is ${status}, Please contact the authorized user to unlock` };
return { locked:false, period }; // OPEN → allowed
}
// No period contains this date:
if (await repo.existsForCompany(companyId)) // company uses fiscal periods → a GAP is treated as locked
return { locked:true, message:"No open fiscal period covers this date. Please create/open the period before posting." };
return { locked:false }; // company doesn't use periods at all → allowvalidateTransactionDate(date, companyId?) resolves the company (arg → contextSvc.companyId), and throws 403 Forbidden with the message when locked. If there is no company context it skips safely (returns).
Three-way outcome: OPEN period → allow, LOCKED/CLOSED period → block, no period but the company has periods (a gap) → block, company has no periods at all → allow (feature unused).
4.4 Status transitions
| method | from → to | guards / side effects |
|---|---|---|
lockPeriod(id, reason?) |
OPEN → LOCKED | rejects if CLOSED; sets lockedBy/lockedAt |
unlockPeriod(id) |
LOCKED → OPEN | rejects if CLOSED; clears lockedBy/lockedAt |
closePeriod(id) |
OPEN/LOCKED → CLOSED | rejects if already CLOSED; sets lockedBy/lockedAt; terminal — no unclose |
lock close
OPEN ───────────▶ LOCKED ───────────▶ CLOSED (terminal)
▲ │ ▲
└────── unlock ────┘────── close ───────┘
4.5 Next fiscal year — createNextFiscalYear(companyId?)
Takes the latest period's endDate, computes next year as [end+1 day @ startOf day, end+1 year @ endOf day] (UTC-anchored), then seedFiscalPeriods. Throws 400 if no existing periods (company must set fiscal-year dates first).
4.6 How locked periods block posting (consumers)
Posting services call fiscalPeriodSvc.validateTransactionDate(...) before writing. Confirmed call sites:
- Finance:
journal.service.ts(journal.documentDate),note.service.ts,transaction.service.ts,cashbook.service.ts,payment.service.ts— see journal.md. - Inventory:
order/order.service.ts(orderDate),order/order.return.ts(returnDate),order/order.transaction.ts. - Assets:
assets.service.ts(purchaseDate/ transactiondocumentDate).
There is also a bypass guard — finance/guards/gql-locked-period.guard.ts (@GuardLockedPeriod decorator). For posting a draft entry it collects the entry's transaction dates, runs isDateLocked per date, and if any is locked it only blocks users who lack the post-to-locked-period permission (RoleActions.POST_LOCKED_PERIOD); privileged users may post into a locked period. Wired on journal/note/cashbook post resolvers.
4.7 Transactionality
Period generation/seeding is a loop of independent upserts (no explicit Mongo session). Lock/unlock/close are single-document updates. The atomicity that matters is in the consumer services, which run their writes inside their own retry transactions after the date check passes.
5. Permissions
- Resolver-level:
@ApGqlAuthorize({})(authenticated). - Admin page
/accounting-periodis SSR-guarded byUSER_ACCESS.COMPANY.MODULE+ actionupdate-accounting-period(USER_ACCESS.COMPANY.ACTIONS.UPDATE_ACCOUNTING_PERIOD) — fiscal-period management is administered under the Company permission module, not a standalone one. - Posting into a locked period requires the
post-to-locked-periodaction (RoleActions.POST_LOCKED_PERIOD) to bypass the lock — see platform/permissions-access.md. - All mutations carry
@AuditMeta({ module:'fiscal', collection:'fiscal_periods' }).
6. Flows
6.1 Seeding on company setup
- On
createCompany/updateCompany, iffiscalYearStartDate&fiscalYearEndDateare set,CompanyServicecallsfiscalPeriodSvc.seedFiscalPeriods(start, end, companyId). - Periods for every month of the year are upserted as
OPEN. (See company-branch.md §4.2.)
6.2 Lock a period (admin → DB)
/accounting-periodpage →FiscalTablerow, statusOPEN→ lock icon →ApConfirmPopover"Lock this fiscal period?".lockPeriod(id)→lockFiscalPeriodmutation → service setsstatus=LOCKED,lockedBy,lockedAt.- Any later attempt to post a transaction dated in that month →
validateTransactionDatethrows403with the period message. Toast: "Fiscal Period Locked".
6.3 Roll forward a year
- Admin →
createNextFiscalYear(companyId?)→ seeds the next 12 months from the last period's end. Toast "Next Fiscal Year Created Successfully".
Unhappy paths: lock/unlock a CLOSED period → 400; close an already-closed period → 400; post into a gap when the company has periods → 403 "create/open the period before posting"; createNextFiscalYear with no existing periods → 400.
7. Admin UI
- Page:
/accounting-period(pages/accounting-period.tsx) →FiscalPageinMainLayout, guarded by Company-moduleupdate-accounting-period. - Module:
fiscalPeriod/{context.tsx, page.tsx, model.ts, gql/{query,fragment}.ts, components/table.tsx}. - Context (
useFiscalState):fetchPeriods(→fiscalPeriodPage),findOnePeriod,lockPeriod,unlockPeriod,closePeriod,createNextFiscalYear; stateperiods,period,filter,totalRecords,modal. components/table.tsx(FiscalTable): AntApTablecolumns Period/Name/Month/Year/Start/End/Status/Actions. Status color: CLOSED red, LOCKED yellow, OPEN green. Dates rendered withfmtDate. Sortable, server-paginated.- Row actions (status-driven, each behind
ApConfirmPopover): OPEN → Lock (yellowFiLock); LOCKED → Unlock (greenFiUnlock); any non-CLOSED → Close (Permanent) (redFiXCircle, "cannot be undone").
8. Dependencies & integrations
- Consumed by finance (journal, note, transaction, cashbook, payment), inventory (order/order-return/order-transaction), and assets — via
validateTransactionDate/ the@GuardLockedPeriodbypass guard. - Seeded by
CompanyServicefrom the company's fiscal-year bounds. FiscalPeriodModuleimports Auth, User, AuditLog, Config, Account, Company (allforwardRef). No cron/jobs, no external services.
9. Gotchas & project-specific rules
- UTC anchoring is intentional. All boundary math uses
dayjs.utc. Switching to local time would store the 1st-of-month at the server TZ (e.g. 23:00Z for a WAT tenant), which renders as the previous day in the admin. Don't "fix" this to local time. - A gap is a lock — but only if the company uses periods. If
findByDatereturns nothing andexistsForCompanyis true, posting is blocked ("create/open the period before posting"). If the company has no periods at all, the feature is treated as off and posting is allowed. This makes fiscal-period enforcement opt-in per company. - No company context = no check.
validateTransactionDatereturns early (allows) when it can't resolve acompanyId— by design, so non-tenant-scoped calls don't crash. - Close is terminal. There is no "reopen a closed period" mutation; only OPEN ⇄ LOCKED is reversible.
- Privileged users can post into locked periods via the
post-to-locked-periodpermission — the lock is a soft wall for those users, hard for everyone else (only on the post-draft path guarded by@GuardLockedPeriod; directvalidateTransactionDatecalls in services have no bypass). FiscalPeriodis not aBaseSchema— noref/branchId/auditcanPost; it is company-scoped via an explicitcompanyIdfilter, not the base-repo auto-scope.- Unique per company+month+year — re-seeding updates dates/name in place and never duplicates a month.