Skip to main content

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 === false or when getParentOrgId() 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=true and a populated void_reversal_entry_number.
  • Mirror lines exist with swapped debits/credits and is_void_reversal=true.
  • Period lock is unchanged (verify accounting_period_locks row).

---

Files of record

  • src/features/admin/components/FiscalYearCloseManager.tsx - this page
  • src/features/admin/components/AdministrationHub.tsx - registers the hub tile
  • src/components/PageRouter.tsx - wires administrationTool === 'fiscal-year-close' to this component
  • src/store/types.ts - adds 'fiscal-year-close' to AdministrationTool
  • src/hooks/useTierAccess.ts - maps the tool to the administration hub for tier checks
  • src/lib/db/fiscal-year-close.ts - RPC wrappers and types
  • documentation/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

Ready to get started?Start Plus Trial