Fiscal Year Close
Fiscal Year Close
Component file: src/features/admin/components/FiscalYearCloseManager.tsx Route / navigation: Administration Hub -> "Close Fiscal Year" tile (administrationTool = 'fiscal-year-close'). Access level: Parent-org admins only. Hidden from child-fund users by parentOrgOnly: true on the hub tile and by an early-return guard inside the page. Engine contract: `documentation/contracts/FISCAL-YEAR-CLOSE.md`. Last updated: April 22, 2026
---
Purpose
The Fiscal Year Close page is the only operator-facing surface that calls the close_fiscal_year and reverse_fiscal_year_close RPCs. It enforces a dry-run preview -> triple-confirm post -> reversible audit flow so a fiscal-year close is never a one-click foot-gun.
This page does not compute anything itself - it is a thin wrapper around the engine, mirroring the engine's per-fund routes back to the operator for review.
---
Workflow
1. Configure
The page derives this from the parent org's fiscal_year_start_month / fiscal_year_start_day settings via fetchOrganizationFiscalYearBoundary(...) and deriveMostRecentCompletedFiscalYearEnd(...). While that lookup is loading, the UI temporarily falls back to Dec 31 of the previous calendar year so the field is never blank.
- Fiscal year end (date input, defaults to the most recently completed fiscal-year end for the selected parent organization).
- Closing note (optional textarea). Stored on
fiscal_year_closes.notes.
2. Dry-run preview
Run dry-run preview -> closeFiscalYear({ dryRun: true }).
The preview card renders the full FiscalYearCloseResult:
| Block | Source | Notes |
|-------|--------|-------|
| Header summary | `dryRun.fiscal_year_start/end`, `version`, `fund_count`, `total_net_income` | Total NI is colored red/green. |
| Warnings | `dryRun.warnings[]` | Non-blocking informational messages. |
| Configuration issues | `dryRun.errors[]` | Each error shows `code`, `fund_name`, `amount`, plus a deep-link hint to the right fix surface (Account Defaults for `*_unrestricted_equity`, Chart of Accounts for `*_temporarily_restricted_equity` and `*_permanently_restricted_equity`). |
| Per-fund routes table | `dryRun.routes[]` | One row per fund, columns per restriction bucket (`Unrestricted`, `Temporarily Restricted`, `Permanently Restricted`). Each cell shows the bucket amount and the resolved equity account (looked up from `accounts` via `fetchAccountLookup` for human readability). |
The post button only renders when dryRun.dry_run === true, dryRun.valid === true, and dryRun.fund_count > 0.
3. Triple-confirm post
Clicking Post fiscal-year close opens a modal with three independent checkboxes:
1. *I have reviewed the per-fund routes and confirmed equity targets are correct.* 2. *I understand the fiscal-year period will be locked at <fy_end> after this close.* 3. *I have authority to close this fiscal year for this organization.*
The "Post" button stays disabled until all three are ticked. Submitting the form calls closeFiscalYear({ dryRun: false }) - the engine's atomicity guarantees apply (all funds post or none do).
On success, the dry-run result is replaced by the live result (same shape, with audit_id populated), the history panel refetches, and balance-sheet / income-statement query keys are invalidated so the rest of the app reflects the new lock and equity balances on next read.
4. History & reversal
The history card lists every row from fiscal_year_closes for the current parent org (newest first). Each posted row exposes a Reverse button that opens the reversal dialog.
The reversal dialog requires a non-empty reversalNote and warns that:
- Mirror entries are posted (originals never deleted).
- The audit row flips to
status='reversed'. - The period lock is not rolled back automatically.
Submitting calls reverseFiscalYearClose(...); on success the history refetches and the balance sheet is invalidated.
---
Permissions
1. UI guard (above) - prevents the button from rendering for non-admin sessions. 2. RPC guard - the engine itself rejects callers who are not parent-org admins (regardless of UI state).
- The page short-circuits to a "Parent organization required" empty state when
usePermissions().isParentOrg === falseor whengetParentOrgId()returns null. - All write paths are double-gated:
This page never assumes RLS will catch a leaked button; the UI guard is for ergonomics, the RPC guard is the source of truth.
---
Data dependencies
| Hook / function | Source | Notes |
|-----------------|--------|-------|
| `usePermissions()` | `src/hooks/usePermissions.ts` | `isParentOrg` |
| `useEntities()` | `src/hooks/useEntities.ts` | parent-org name in header |
| `useAppStore(s => s.selectedEntity)` | `src/store/index.ts` | for parent-org resolution |
| `fetchFiscalYearCloses(parentOrgId)` | `src/lib/db/fiscal-year-close.ts` | history list |
| `closeFiscalYear({...})` | `src/lib/db/fiscal-year-close.ts` | dry-run + post || `reverseFiscalYearClose({...})` | `src/lib/db/fiscal-year-close.ts` | reversal || `fetchAccountLookup(parentOrgId)` | inline | maps `equity_account_id` -> `code · name` for the routes table |
closeFiscalYear and reverseFiscalYearClose surface RPC errors verbatim through toast.error. Validation issues from the dry run come back in result.errors[] and render in the preview card (NOT as toasts) so the operator can read them all at once.
---
QA checklist
When changing this page:
1. Run npx tsc --noEmit and confirm no new errors (the pre-existing src/types/supabase.ts BOM noise is expected). 2. Walk through the dry-run flow at least once with invalid configuration (missing unrestricted-NA default, no temporarily-restricted equity account) and confirm the error list renders with the right deep-link hint. 3. Walk through a successful dry-run + post on a parent-org test environment, then run supabase/queries/verify_fiscal_year_close.sql against the returned fiscal_year_end and confirm zero rows from each of the three checks. 4. Walk through a reversal and confirm:
5. Confirm the Reverse button does not render for status='reversed' rows. 6. Re-post the same fiscal year after reversal and confirm the new audit row is version=v(N+1). The audit-row reference_id is deterministic and repeats across versions (fy-close-<fy_end>-<parent_org_id>); only version increments, and the per-fund JE reference_ids carry the new -v<version> suffix (fy-close-<fy_end>-<parent_org_id>-<fund_id>-v<version>).
- Audit row flips to
status='reversed'. - Original lines have
is_voided=trueand a populatedvoid_reversal_entry_number. - Mirror lines exist with swapped debits/credits and
is_void_reversal=true. - Period lock is unchanged (verify
accounting_period_locksrow).
---
Files of record
src/features/admin/components/FiscalYearCloseManager.tsx- this pagesrc/features/admin/components/AdministrationHub.tsx- registers the hub tilesrc/components/PageRouter.tsx- wiresadministrationTool === 'fiscal-year-close'to this componentsrc/store/types.ts- adds'fiscal-year-close'toAdministrationToolsrc/hooks/useTierAccess.ts- maps the tool to theadministrationhub for tier checkssrc/lib/db/fiscal-year-close.ts- RPC wrappers and typesdocumentation/contracts/FISCAL-YEAR-CLOSE.md- engine contract (required reading before any change)
Synced from IFMmvp-Frontend documentation: pages/administration/07-FISCAL-YEAR-CLOSE.md
Ready to Get Started?
See how Alignmint can simplify your nonprofit's operations. Schedule a free demo with our team and we'll walk you through everything.
Questions? Email us at steven@getalignmint.org