Skip to content

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 allowed
  • SOFT_CLOSED — only ADMINISTRATOR users can post into the period
  • HARD_CLOSED — no postings allowed (backend will reject with PeriodClosedError)

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:

  1. A CompanySetting row must exist with key retained_earnings_account_id (string) and a value equal to the UUID of an existing equity-type account. Surface a setup screen under company settings.
  2. All bank reconciliations for the year should be closed (not enforced server-side yet — surface as a frontend warning).

GraphQL surface

Queries

fiscalYears: [FiscalYear!]!
fiscalYear(fiscalYearId: UUID!): FiscalYear!

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 / hardCloseAccountingPeriod should be one-click from the period list with a confirmation modal. Always show who closed it and when (the response carries closedAt + closedById).
  • closeFiscalYear is the highest-stakes action — show a confirmation that lists: retained-earnings account name, total revenue and expense to be closed (frontend can derive from incomeStatement query for the year), and warns that all 12 periods will become HARD_CLOSED.
  • reopenFiscalYear reverses 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

  1. Year setup screen — table of fiscal years with status badge, columns for start/end, period count, "Close" / "Reopen" actions.
  2. 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.
  3. 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 closingEntryId to the journal entry detail page.