Files
accounted/dev_docs/base_architecture/PART2_IMPLEMENTATION.md
T
Jakob Wennberg cdf1dcc4c8 New Base func
2026-02-19 09:48:02 +01:00

9.1 KiB
Raw Blame History

Part 2: Period Management & Year-End Closing

Overview

Part 2 implements year-end closing (årsbokslut) — the process that legally closes a fiscal year per Bokföringslagen. This includes period locking, closing entry generation, opening balance propagation, and the API surface to drive the workflow.

Depends on Part 1: immutable ledger, audit trail, tax codes, document archive, period lock enforcement, retention protection.


What Was Built

Migration 19: Period Closing Metadata

File: supabase/migrations/20240101000019_period_closing.sql

Three new columns on fiscal_periods:

Column Type Purpose
closing_entry_id uuid FK → journal_entries Links to the year-end closing journal entry
opening_balance_entry_id uuid FK → journal_entries Links to the opening balance entry in this period
previous_period_id uuid FK → fiscal_periods Chain link to the prior period for validation

One new trigger:

  • enforce_opening_balance_immutability — Once opening_balance_entry_id or closing_entry_id are set, they cannot be changed. This prevents tampering with the closing chain after the fact.

TypeScript Types

File: types/index.ts

Extended FiscalPeriod with the three new nullable fields.

New interfaces:

Interface Purpose
YearEndValidation Result of readiness check: ready, errors[], warnings[], draftCount, voucherGaps[], trialBalanceBalanced
YearEndPreview Preview of closing: netResult, closingAccount (2099/2010), closingLines[], resultAccountSummary[]
YearEndResult Result of execution: closingEntry, nextPeriod, openingBalanceEntry
PeriodStatus Status summary: lock/close/draft/opening state

Period Service

File: lib/core/bookkeeping/period-service.ts

Function What it does
lockPeriod(userId, fiscalPeriodId) Sets locked_at = now(). Validates period exists, belongs to user, isn't already locked/closed. After locking, the enforce_period_lock trigger (from Part 1) blocks new journal entries.
closePeriod(userId, fiscalPeriodId) Sets is_closed = true, closed_at = now(). Requires: already locked AND closing_entry_id is set. This is the final, permanent state.
createNextPeriod(userId, currentPeriodId) Creates the next fiscal year. Computes dates from the current period's length to handle brutet räkenskapsår (broken fiscal years, e.g. JulJun). Sets previous_period_id for chain validation. Auto-generates name like "FY 2025" or "FY 2025/2026".
getPeriodStatus(userId, fiscalPeriodId) Returns a summary: is_locked, is_closed, has_closing_entry, has_opening_balances, draft_count, next_period_exists.

Year-End Service

File: lib/core/bookkeeping/year-end-service.ts

This is the core new logic.

validateYearEndReadiness(userId, fiscalPeriodId)YearEndValidation

Checks preconditions before allowing year-end closing:

  • Blocking errors (prevent closing):
    • Period already closed
    • Closing entry already exists
    • Draft journal entries exist (must be posted or deleted)
    • Trial balance is not balanced
  • Warnings (informational):
    • Voucher number gaps detected (via detect_voucher_gaps() SQL function)
    • No posted entries in the period

previewYearEndClosing(userId, fiscalPeriodId)YearEndPreview

Generates a preview without persisting anything:

  1. Looks up entity_type from company_settings → determines closing account:
    • Aktiebolag (AB): account 2099 (Årets resultat)
    • Enskild firma (EF): account 2010 (Eget kapital)
  2. Runs income statement to get net_result
  3. Gets trial balance, filters to class 38 accounts
  4. For each account with a non-zero balance: creates a line that zeros it
  5. Adds a final balancing line to the closing account (2099/2010)
  6. Returns the preview with all lines and a summary of result accounts

executeYearEndClosing(userId, fiscalPeriodId)YearEndResult

Full orchestration (the main entry point):

1. validateYearEndReadiness()     → abort if errors
2. previewYearEndClosing()        → get closing lines
3. createJournalEntry()           → create closing entry (source_type: 'year_end')
4. UPDATE fiscal_periods          → set closing_entry_id
5. lockPeriod()                   → lock the period
6. closePeriod()                  → permanently close
7. createNextPeriod()             → create next fiscal year
8. generateOpeningBalances()      → carry forward class 1-2 balances
9. Return { closingEntry, nextPeriod, openingBalanceEntry }

generateOpeningBalances(userId, closedPeriodId, nextPeriodId)JournalEntry

Creates opening balance entries in the new period:

  1. Gets trial balance of the closed period (after closing entry)
  2. Filters to balance sheet accounts (class 12) with non-zero closing balance
  3. Creates a journal entry with source_type: 'opening_balance':
    • Debit accounts get debit opening, credit accounts get credit opening
  4. Verifies the entry is balanced (total debit = total credit)
  5. Sets opening_balance_entry_id and opening_balances_set = true on the next period

Key invariant: UB (utgående balans) of year N == IB (ingående balans) of year N+1.


API Routes

All follow the existing pattern: authenticate via Supabase, delegate to service, return JSON.

Method Path Handler
POST /api/bookkeeping/fiscal-periods/[id]/lock lockPeriod()
GET /api/bookkeeping/fiscal-periods/[id]/year-end validateYearEndReadiness() + previewYearEndClosing()
POST /api/bookkeeping/fiscal-periods/[id]/year-end executeYearEndClosing()
POST /api/bookkeeping/fiscal-periods/[id]/close closePeriod()

Reused Components

Component From Used by
generateTrialBalance() lib/reports/trial-balance.ts Balance aggregation for closing + opening entries
generateIncomeStatement() lib/reports/income-statement.ts Net result calculation
createJournalEntry() lib/bookkeeping/engine.ts Creating closing + opening entries (auto-posts)
validateBalance() lib/bookkeeping/engine.ts Pre-flight balance check
detect_voucher_gaps() Migration 16 SQL function Gap validation during readiness check
enforce_period_lock trigger Migration 17 Blocks writes after locking
enforce_journal_entry_immutability trigger Migration 17 Protects closing/opening entries after posting

Period Lifecycle Diagram

                    ┌─────────┐
                    │  OPEN   │  ← Journal entries can be posted
                    └────┬────┘
                         │ lockPeriod()
                         ▼
                    ┌─────────┐
                    │ LOCKED  │  ← No new entries (enforce_period_lock trigger)
                    └────┬────┘
                         │ closePeriod() (requires closing_entry_id)
                         ▼
                    ┌─────────┐
                    │ CLOSED  │  ← Permanent, immutable
                    └─────────┘

The executeYearEndClosing() function drives the full flow from OPEN → CLOSED in one call, including creating the closing entry, locking, closing, creating the next period, and generating opening balances.


Verification Checklist

  • npx tsc --noEmit — zero TypeScript errors
  • Migration 19 applies cleanly (\d fiscal_periods shows new columns)
  • GET /api/bookkeeping/fiscal-periods/[id]/year-end returns preview with net result
  • POST /api/bookkeeping/fiscal-periods/[id]/year-end creates closing entry, locks, closes, creates next period, generates opening balances
  • Closing entry zeros all class 38 accounts
  • Opening balance entry in next period matches UB of closed period (class 12 only)
  • Closed period rejects new journal entries (period lock trigger)
  • Period with draft entries → validation fails with blocking error
  • Period already closed → validation fails
  • EF entity type → closing goes to 2010 (not 2099)

Files Changed/Created

File Action
supabase/migrations/20240101000019_period_closing.sql Created — 3 ALTER columns + 1 trigger
types/index.ts Modified — extended FiscalPeriod, added 4 new interfaces
lib/core/bookkeeping/period-service.ts Created — lockPeriod, closePeriod, createNextPeriod, getPeriodStatus
lib/core/bookkeeping/year-end-service.ts Created — validateYearEndReadiness, previewYearEndClosing, executeYearEndClosing, generateOpeningBalances
app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts Created — POST lock endpoint
app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts Created — GET preview + POST execute
app/api/bookkeeping/fiscal-periods/[id]/close/route.ts Created — POST close endpoint