Fiscal Periods — the posting calendar & period lock

The whole module reduces to one idea: a company's financial year is sliced into monthly FiscalPeriod rows, and a transaction may only post if its document date falls inside an OPEN period. 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 FiscalPeriod row 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 a LOCKED/CLOSED month.
  • 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_periodsfiscal/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: FiscalPeriod does not extend BaseSchema (it is a plain @ApSchema class), so it has no ref/branchId/canPost fields. It carries companyId explicitly and is queried by it directly — it is not auto-scoped by the base repository's companyMatch the way BaseSchema collections are. The GraphQL IFiscalPeriod model also surfaces UI-only canLock/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

generateFiscalPeriods returns the computed list without saving (preview); seedFiscalPeriods persists them (upsert by month+year). Company create/update calls seedFiscalPeriods internally — 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 → allow

validateTransactionDate(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 / transaction documentDate).

There is also a bypass guardfinance/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-period is SSR-guarded by USER_ACCESS.COMPANY.MODULE + action update-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-period action (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

  1. On createCompany/updateCompany, if fiscalYearStartDate & fiscalYearEndDate are set, CompanyService calls fiscalPeriodSvc.seedFiscalPeriods(start, end, companyId).
  2. Periods for every month of the year are upserted as OPEN. (See company-branch.md §4.2.)

6.2 Lock a period (admin → DB)

  1. /accounting-period page → FiscalTable row, status OPEN → lock icon → ApConfirmPopover "Lock this fiscal period?".
  2. lockPeriod(id)lockFiscalPeriod mutation → service sets status=LOCKED, lockedBy, lockedAt.
  3. Any later attempt to post a transaction dated in that month → validateTransactionDate throws 403 with the period message. Toast: "Fiscal Period Locked".

6.3 Roll forward a year

  1. 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) → FiscalPage in MainLayout, guarded by Company-module update-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; state periods, period, filter, totalRecords, modal.
  • components/table.tsx (FiscalTable): Ant ApTable columns Period/Name/Month/Year/Start/End/Status/Actions. Status color: CLOSED red, LOCKED yellow, OPEN green. Dates rendered with fmtDate. Sortable, server-paginated.
  • Row actions (status-driven, each behind ApConfirmPopover): OPEN → Lock (yellow FiLock); LOCKED → Unlock (green FiUnlock); any non-CLOSED → Close (Permanent) (red FiXCircle, "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 @GuardLockedPeriod bypass guard.
  • Seeded by CompanyService from the company's fiscal-year bounds.
  • FiscalPeriodModule imports Auth, User, AuditLog, Config, Account, Company (all forwardRef). 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 findByDate returns nothing and existsForCompany is 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. validateTransactionDate returns early (allows) when it can't resolve a companyId — 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-period permission — the lock is a soft wall for those users, hard for everyone else (only on the post-draft path guarded by @GuardLockedPeriod; direct validateTransactionDate calls in services have no bypass).
  • FiscalPeriod is not a BaseSchema — no ref/branchId/audit canPost; it is company-scoped via an explicit companyId filter, not the base-repo auto-scope.
  • Unique per company+month+year — re-seeding updates dates/name in place and never duplicates a month.