Period close & fiscal-year close¶
Backend domain: app/graphql/accounting_periods/
Migration: add_fiscal_years_periods
RBAC Path: PERIOD_CLOSE
Overview¶
Fiscal years are created with explicit start/end dates; the backend automatically materializes monthly AccountingPeriod rows. Each period has three statuses:
OPEN— postings allowedSOFT_CLOSED— onlyADMINISTRATORusers can post into the periodHARD_CLOSED— no postings allowed (backend will reject withPeriodClosedError)
closeFiscalYear posts an automatic closing JE that zeros revenue + expense accounts into the configured retained-earnings account, then hard-closes every period for the year.
Setup prerequisites¶
Before closeFiscalYear will succeed:
- A
CompanySettingrow must exist with keyretained_earnings_account_id(string) and a value equal to the UUID of an existing equity-type account. Surface a setup screen under company settings. - All bank reconciliations for the year should be closed (not enforced server-side yet — surface as a frontend warning).
GraphQL surface¶
Queries¶
FiscalYear exposes: id, name, startDate, endDate, status, closedAt, closedById, closingEntryId, and a nested periods: [AccountingPeriod!]!.
AccountingPeriod exposes: id, fiscalYearId, periodNumber (1-12), startDate, endDate, status, closedAt, closedById.
Mutations¶
createFiscalYear(fiscalYear: FiscalYearInput!): FiscalYear!
softCloseAccountingPeriod(periodId: UUID!): AccountingPeriod!
hardCloseAccountingPeriod(periodId: UUID!): AccountingPeriod!
reopenAccountingPeriod(periodId: UUID!): AccountingPeriod!
closeFiscalYear(
fiscalYearId: UUID!,
skipBankRecCheck: Boolean = false
): FiscalYear!
reopenFiscalYear(fiscalYearId: UUID!): FiscalYear!
closeFiscalYear now refuses to close unless every BankAccount has a BankReconciliation with status RECONCILED and periodEnd >= year.endDate. Override with skipBankRecCheck: true (admin only — surface as a confirm-with-reason dialog) when the tenant legitimately has no bank accounts or has accepted an exception.
FiscalYearInput: name, startDate, endDate.
Behaviour to handle in the UI¶
- After
createFiscalYear, refresh the year list — the new year arrives with 12 (or fewer, if partial year) periods already populated. softCloseAccountingPeriod/hardCloseAccountingPeriodshould be one-click from the period list with a confirmation modal. Always show who closed it and when (the response carriesclosedAt+closedById).closeFiscalYearis the highest-stakes action — show a confirmation that lists: retained-earnings account name, total revenue and expense to be closed (frontend can derive fromincomeStatementquery for the year), and warns that all 12 periods will becomeHARD_CLOSED.reopenFiscalYearreverses the closing JE; surface as admin-only with a strong confirmation.
Error states¶
| Backend exception | When | Frontend message |
|---|---|---|
PeriodClosedError (raised on any posting mutation, not on these mutations) |
User tries to post an invoice/receipt/etc. dated in a closed period | "Period closed: invoices dated MM/YYYY can't be saved. Ask an admin to soft-reopen the period." |
FiscalYearAlreadyClosedError |
closeFiscalYear on an already-closed year |
"Fiscal year YYYY is already closed." |
FiscalYearNotReadyError |
closeFiscalYear without retained_earnings_account_id configured |
"Configure the retained-earnings account in Settings → Accounting before closing the year." |
Suggested UX flow¶
- Year setup screen — table of fiscal years with status badge, columns for start/end, period count, "Close" / "Reopen" actions.
- Period strip — drill into a year to see a horizontal calendar of 12 periods with status colour (green/yellow/red) and per-period close/reopen.
- Year-end close wizard — single dialog: (1) show retained-earnings preview, (2) show summary of revenue/expense to close, (3) confirm.
Open follow-ups (not yet implemented)¶
- No drill-through from the closing JE to the period in this version; once added, link
closingEntryIdto the journal entry detail page.