Public/Test-LedgerFiscalYear.ps1

<#
.SYNOPSIS
Runs year-end readiness checks (bokslutskontroll) on a fiscal year.

.DESCRIPTION
Validates that a fiscal year is ready to be closed and returns one result object
per check with Check, Status ('Pass', 'Fail', 'Warning' or 'Info') and Detail
properties. Nothing is modified. The checks are:

- VerificationsBalance - every verification's rows sum to zero (Fail).
- VerificationNumbering - verification numbers are contiguous from 1, no gaps (Fail).
- NoEmptyVerifications - every verification has at least two rows (Fail).
- LedgerBalances - all closing balances across every account sum to zero (Fail).
- OpeningBalanceBalances - the opening balance (ib.txt) itself sums to zero (Warning).
- OpeningBalanceMatchesPrevious - the opening balance reconciles with the previous
  fiscal year's closing balance sheet, including its carried net result (Warning;
  Info when there is no previous fiscal year).
- HoldingsValuation - no account or holding recorded with Set-LedgerHolding has a
  market value below its book value (Warning). For 13xx accounts the decline must
  be assessed as lasting or not; for 18xx accounts the lower of cost and market
  value applies. Info when there is no holdings.txt.
- HoldingsReconcile - the summed BookValue of an account's holdings (when all have
  one) matches the ledger balance, the summed Cost (when all have one) matches the
  account's own balance (e.g. 1350 without 1359), and every PriceDate is the
  balance date (Warning; Info when there is no holdings.txt).
- HoldingsReversal - no earlier write-down may be reversed: a holding with both
  BookValue and Cost whose market value has recovered above its book value is
  flagged for återföring (Warning; Info when no holding records a Cost).

VAT reconciliation is not yet included. Use `Where-Object { $_.Status -eq 'Fail' }`
to gate an automated close on the hard checks.

.PARAMETER JournalPath
The path to an existing journal directory.

.PARAMETER FiscalYear
The fiscal year identifier (e.g. '2024-01_2024-12').

.EXAMPLE
Test-LedgerFiscalYear -JournalPath .\MinFirma.ledger -FiscalYear '2024-01_2024-12'

Returns one object per check describing the year-end readiness of 2024.

.EXAMPLE
$failures = Test-LedgerFiscalYear -JournalPath .\MinFirma.ledger -FiscalYear '2024-01_2024-12' |
    Where-Object { $_.Status -eq 'Fail' }
if (-not $failures) { Close-LedgerFiscalYear -JournalPath .\MinFirma.ledger -FiscalYear '2024-01_2024-12' }

Closes the year only when no hard check fails.
#>

function Test-LedgerFiscalYear {
    [OutputType([pscustomobject])]
    [CmdletBinding()]
    param (
        [Parameter()]
        [string]$JournalPath,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('Name')]
        [string]$FiscalYear
    )
    process {
        $JournalPath = Resolve-LedgerJournalPath -JournalPath $JournalPath
        $FiscalYear = Resolve-LedgerFiscalYear -FiscalYear $FiscalYear -JournalPath $JournalPath

        $YearDir = Join-Path $JournalPath $FiscalYear
        if (-not (Test-Path $YearDir -PathType Container)) {
            throw "Fiscal year not found: $FiscalYear"
        }

        function New-CheckResult {
            param ([string]$Check, [string]$Status, [string]$Detail)
            [PSCustomObject]@{
                FiscalYear = $FiscalYear
                Check      = $Check
                Status     = $Status
                Detail     = $Detail
            }
        }

        $Entries = @(Get-LedgerEntry -JournalPath $JournalPath -FiscalYear $FiscalYear)

        # --- VerificationsBalance ---
        $Unbalanced = foreach ($Entry in $Entries) {
            $Sum = ($Entry.Rows | Measure-Object -Property Amount -Sum).Sum
            if ([Math]::Round([decimal]$Sum, 2) -ne 0) {
                'ver{0:0000} (sum {1})' -f $Entry.VerificationNumber, ([Math]::Round([decimal]$Sum, 2))
            }
        }
        $Unbalanced = @($Unbalanced)
        if ($Unbalanced.Count -gt 0) {
            New-CheckResult 'VerificationsBalance' 'Fail' ("Unbalanced verifications: " + ($Unbalanced -join ', '))
        }
        else {
            New-CheckResult 'VerificationsBalance' 'Pass' "All $($Entries.Count) verification(s) balance."
        }

        # --- VerificationNumbering ---
        $Numbers = @($Entries | ForEach-Object { $_.VerificationNumber } | Sort-Object)
        if ($Numbers.Count -eq 0) {
            New-CheckResult 'VerificationNumbering' 'Info' 'No verifications in this fiscal year.'
        }
        else {
            $Max = ($Numbers | Measure-Object -Maximum).Maximum
            $Present = @{}
            foreach ($n in $Numbers) { $Present[$n] = $true }
            $Missing = for ($i = 1; $i -le $Max; $i++) { if (-not $Present.ContainsKey($i)) { $i } }
            $Missing = @($Missing)
            if ($Missing.Count -gt 0) {
                $MissingNames = $Missing | ForEach-Object { 'ver{0:0000}' -f $_ }
                New-CheckResult 'VerificationNumbering' 'Fail' ("Missing verification number(s): " + ($MissingNames -join ', '))
            }
            else {
                New-CheckResult 'VerificationNumbering' 'Pass' "Verifications numbered 1..$Max with no gaps."
            }
        }

        # --- NoEmptyVerifications ---
        $Empty = foreach ($Entry in $Entries) {
            if (@($Entry.Rows).Count -lt 2) {
                'ver{0:0000}' -f $Entry.VerificationNumber
            }
        }
        $Empty = @($Empty)
        if ($Empty.Count -gt 0) {
            New-CheckResult 'NoEmptyVerifications' 'Fail' ("Verifications with fewer than two rows: " + ($Empty -join ', '))
        }
        else {
            New-CheckResult 'NoEmptyVerifications' 'Pass' 'All verifications have at least two rows.'
        }

        # --- LedgerBalances ---
        $Balance = @(Get-LedgerBalance -JournalPath $JournalPath -FiscalYear $FiscalYear)
        $Total = ($Balance | Measure-Object -Property Balance -Sum).Sum
        $Total = [Math]::Round([decimal]$Total, 2)
        if ($Total -ne 0) {
            New-CheckResult 'LedgerBalances' 'Fail' "Closing balances do not net to zero (sum $Total)."
        }
        else {
            New-CheckResult 'LedgerBalances' 'Pass' 'All closing balances net to zero.'
        }

        # --- OpeningBalanceBalances ---
        # Read-LedgerOpeningBalance already returns an array (comma-protected), so
        # assign directly without @() which would double-wrap it.
        $Opening = Read-LedgerOpeningBalance -YearDir $YearDir
        if (@($Opening).Count -eq 0) {
            New-CheckResult 'OpeningBalanceBalances' 'Info' 'No opening balance (ib.txt) for this fiscal year.'
        }
        else {
            $OpeningSum = ($Opening | Measure-Object -Property Amount -Sum).Sum
            $OpeningSum = [Math]::Round([decimal]$OpeningSum, 2)
            if ($OpeningSum -ne 0) {
                New-CheckResult 'OpeningBalanceBalances' 'Warning' "Opening balance does not net to zero (sum $OpeningSum)."
            }
            else {
                New-CheckResult 'OpeningBalanceBalances' 'Pass' 'Opening balance nets to zero.'
            }
        }

        # --- OpeningBalanceMatchesPrevious ---
        $AllYears = @(Get-LedgerFiscalYear -JournalPath $JournalPath | Sort-Object StartDate)
        $Names = @($AllYears | ForEach-Object { $_.Name })
        $Index = $Names.IndexOf($FiscalYear)
        if ($Index -lt 1) {
            New-CheckResult 'OpeningBalanceMatchesPrevious' 'Info' 'No previous fiscal year to reconcile against.'
        }
        else {
            $PrevName = $Names[$Index - 1]
            $PrevBalance = @(Get-LedgerBalance -JournalPath $JournalPath -FiscalYear $PrevName)

            # Expected opening = previous closing balance-sheet accounts (1xxx/2xxx),
            # plus the previous year's net result carried to the equity result
            # account (this mirrors Copy-LedgerOpeningBalance; the account depends on
            # CompanyType, AB -> 2099 / EF -> 2019, and the result term is zero once
            # it is booked).
            $Expected = @{}
            foreach ($Row in $PrevBalance) {
                if ($Row.AccountNumber -match '^[12]') {
                    $Expected[$Row.AccountNumber] = [Math]::Round([decimal]$Row.Balance, 2)
                }
            }
            $PrevResult = ($PrevBalance | Where-Object { $_.AccountNumber -match '^[3-8]' } |
                Measure-Object -Property Balance -Sum).Sum
            $PrevResult = [Math]::Round([decimal]$PrevResult, 2)
            if ($PrevResult -ne 0) {
                $ResultAccount = Resolve-LedgerResultCarryAccount -JournalPath $JournalPath
                $CurrentResult = if ($Expected.ContainsKey($ResultAccount)) { $Expected[$ResultAccount] } else { [decimal]0 }
                $Expected[$ResultAccount] = [Math]::Round($CurrentResult + $PrevResult, 2)
            }

            $OpeningMap = @{}
            foreach ($Row in $Opening) {
                $OpeningMap[$Row.Account] = [Math]::Round([decimal]$Row.Amount, 2)
            }

            $Accounts = [System.Collections.Generic.HashSet[string]]::new()
            foreach ($k in $Expected.Keys) { [void]$Accounts.Add($k) }
            foreach ($k in $OpeningMap.Keys) { [void]$Accounts.Add($k) }

            $Mismatches = foreach ($Acc in ($Accounts | Sort-Object)) {
                $Exp = if ($Expected.ContainsKey($Acc)) { $Expected[$Acc] } else { [decimal]0 }
                $Got = if ($OpeningMap.ContainsKey($Acc)) { $OpeningMap[$Acc] } else { [decimal]0 }
                if ($Exp -ne $Got) {
                    "$Acc (expected $Exp, got $Got)"
                }
            }
            $Mismatches = @($Mismatches)
            if ($Mismatches.Count -gt 0) {
                New-CheckResult 'OpeningBalanceMatchesPrevious' 'Warning' ("Opening balance does not match $PrevName closing: " + ($Mismatches -join '; '))
            }
            else {
                New-CheckResult 'OpeningBalanceMatchesPrevious' 'Pass' "Opening balance reconciles with $PrevName closing."
            }
        }

        # --- HoldingsValuation / HoldingsReconcile ---
        $Valuation = Get-LedgerHoldingValuation -JournalPath $JournalPath -FiscalYear $FiscalYear
        if (-not $Valuation.HasHoldings) {
            New-CheckResult 'HoldingsValuation' 'Info' 'No holdings (holdings.txt) for this fiscal year.'
            New-CheckResult 'HoldingsReconcile' 'Info' 'No holdings (holdings.txt) for this fiscal year.'
            New-CheckResult 'HoldingsReversal' 'Info' 'No holdings (holdings.txt) for this fiscal year.'
        }
        else {
            $Findings = @(Get-LedgerHoldingValuationFinding -Valuation $Valuation)
            if ($Findings.Count -gt 0) {
                New-CheckResult 'HoldingsValuation' 'Warning' ($Findings -join '; ')
            }
            else {
                New-CheckResult 'HoldingsValuation' 'Pass' 'No holding has a market value below its book value.'
            }

            $YearInfo = $AllYears | Where-Object { $_.Name -eq $FiscalYear } | Select-Object -First 1
            $BalanceDate = if ($YearInfo -and $YearInfo.EndDate) { ([datetime]$YearInfo.EndDate).ToString('yyyy-MM-dd') } else { $null }

            $Issues = @(
                foreach ($Acc in $Valuation.Accounts) {
                    if ($null -ne $Acc.HoldingsBookValue -and [Math]::Round($Acc.HoldingsBookValue, 2) -ne [Math]::Round($Acc.BookValue, 2)) {
                        "Account $($Acc.Account): holdings book value $($Acc.HoldingsBookValue) does not match the ledger balance $($Acc.BookValue)"
                    }
                    if ($null -ne $Acc.HoldingsCost -and [Math]::Round($Acc.HoldingsCost, 2) -ne [Math]::Round($Acc.AccountBalance, 2)) {
                        "Account $($Acc.Account): holdings cost $($Acc.HoldingsCost) does not match the account balance $($Acc.AccountBalance)"
                    }
                }
                foreach ($H in $Valuation.Holdings) {
                    if ($BalanceDate -and $H.PriceDate -and $H.PriceDate -ne $BalanceDate) {
                        "Holding '$($H.Name)' ($($H.Account)): price date $($H.PriceDate) is not the balance date $BalanceDate"
                    }
                }
            )
            if ($Issues.Count -gt 0) {
                New-CheckResult 'HoldingsReconcile' 'Warning' ($Issues -join '; ')
            }
            else {
                New-CheckResult 'HoldingsReconcile' 'Pass' 'Holdings reconcile with the ledger.'
            }

            $WithCost = @($Valuation.Holdings | Where-Object { $null -ne $_.Reversible })
            $Reversals = @($WithCost | Where-Object { $_.Reversible -gt 0 } | ForEach-Object {
                    "Holding '$($_.Name)' ($($_.Account)): market value $(Format-LedgerHoldingAmount $_.MarketValue) exceeds book value $(Format-LedgerHoldingAmount $_.BookValue) - up to $(Format-LedgerHoldingAmount $_.Reversible) of an earlier write-down may be reversed (återföring, Add-LedgerImpairment -Reverse)"
                })
            if ($WithCost.Count -eq 0) {
                New-CheckResult 'HoldingsReversal' 'Info' 'No holding records a Cost (anskaffningsvärde).'
            }
            elseif ($Reversals.Count -gt 0) {
                New-CheckResult 'HoldingsReversal' 'Warning' ($Reversals -join '; ')
            }
            else {
                New-CheckResult 'HoldingsReversal' 'Pass' 'No write-down to reverse.'
            }
        }
    }
}