Public/Close-LedgerFiscalYear.ps1
|
<# .SYNOPSIS Closes a fiscal year, booking the net result and preventing further entries. .DESCRIPTION Performs the year-end closing of a fiscal year. Unless -SkipResultEntry is used, the net result for the year (the sum of the profit and loss accounts, classes 3-8) is booked as a normal verification transferring it to equity: debit the result account (8999 Årets resultat by default) and credit the equity account for a profit, and the reverse for a loss. The equity account defaults to the one that matches the journal's CompanyType (2099 for AB, 2019 for EF) and can be overridden with -EquityAccount. Booking the result as a real verification means it appears in the ledger and in SIE exports, and it consumes a verification number. When there is no result to book (the profit and loss accounts net to zero) no verification is created and the year is simply flagged as closed. After any result entry has been written the Status field in year.txt is set to 'Closed'. Once closed, Add-LedgerEntry refuses to create new verifications in that fiscal year. Closing is safely resumable. Before anything is written, an immutable plan in <year>/closing.txt records the result amount and accounts and reserves the result verification number. If the command is interrupted, run it again with the same options: it completes the saved closing without booking the result twice. Recovery stops if the year's result has changed since the plan was saved. The plan is removed once the year is closed and sealed. Finally every verification and attachment in the year that is not yet in the integrity chain is sealed (see Protect-LedgerFiscalYear), and the command returns an object with FiscalYear, ResultVerification (the result entry's number, or empty) and ChainHash. Save the ChainHash outside the journal, for example in the annual report archive, and check it later with Test-LedgerIntegrity -ExpectedHash. .PARAMETER JournalPath The path to an existing journal directory. .PARAMETER FiscalYear The fiscal year identifier (e.g. '2024-01_2024-12'). .PARAMETER EquityAccount The equity account the net result is transferred to. When omitted it is derived from the journal's CompanyType (AB -> 2099, EF -> 2019). Required for other company forms (HB, KB) or journals without a CompanyType when there is a result to book. .PARAMETER ResultAccount The profit and loss closing account debited/credited against equity. Defaults to '8999' (Årets resultat). .PARAMETER SkipResultEntry Closes the year by only setting its status, without booking the net result. Use this to keep the legacy behaviour where the result is carried forward synthetically by Copy-LedgerOpeningBalance instead of being booked. .EXAMPLE Close-LedgerFiscalYear -JournalPath .\MinFirma.ledger -FiscalYear '2024-01_2024-12' Books the 2024 net result to equity (using the journal's CompanyType to pick the account), closes the year so no more entries can be added and returns the chain hash to keep. .EXAMPLE Close-LedgerFiscalYear -JournalPath .\Enskild.ledger -FiscalYear '2024-01_2024-12' -EquityAccount '2019' Closes an enskild firma year, transferring the result to account 2019 (Årets resultat, eget kapital). .EXAMPLE Get-LedgerFiscalYear -JournalPath .\MinFirma.ledger | Where-Object { $_.Status -eq 'Open' } | ForEach-Object { Close-LedgerFiscalYear -JournalPath .\MinFirma.ledger -FiscalYear $_.Name -SkipResultEntry } Closes all open fiscal years without booking a result verification. #> function Close-LedgerFiscalYear { [OutputType([pscustomobject])] [CmdletBinding(SupportsShouldProcess)] param ( [Parameter()] [string]$JournalPath, [Parameter(ValueFromPipelineByPropertyName)] [Alias('Name')] [string]$FiscalYear, [Parameter()] [string]$EquityAccount, [Parameter()] [string]$ResultAccount = '8999', [Parameter()] [switch]$SkipResultEntry ) process { $JournalPath = Resolve-LedgerJournalPath -JournalPath $JournalPath -SchemaCheck Write $journalLock = $null try { $journalLock = Enter-LedgerJournalLock -JournalPath $JournalPath Assert-LedgerJournalSchema -Path $JournalPath -Write $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" } $YearFile = Join-Path $YearDir 'year.txt' if (-not (Test-Path $YearFile)) { throw "Invalid fiscal year - year.txt not found in: $FiscalYear" } $Lines = Get-Content $YearFile # Check current status and capture the year's end date for the result entry. $EndDate = $null $closed = $false foreach ($Line in $Lines) { if ($Line -match '^Status:\s*Closed') { $closed = $true } elseif ($Line -match '^EndDate:\s*(.+)$') { $EndDate = [datetime]$Matches[1] } } $closingPath = Get-LedgerYearClosingPath -JournalPath $JournalPath -FiscalYear $FiscalYear $operation = if (Test-Path -LiteralPath $closingPath) { Read-LedgerYearClosing -Path $closingPath } else { $null } $entryParameters = $null $existingVerification = $false if ($closed) { # A closing interrupted after the status change only lacks its final seal. $check = Test-LedgerIntegrity -JournalPath $JournalPath -FiscalYear $FiscalYear if (-not $operation) { throw "Fiscal year $FiscalYear is already closed." } # The new Closed status may not be sealed yet. if (@($check.Issues | Where-Object { $_.Problem -ne 'Unsealed' -and -not ($_.Kind -eq 'Metadata' -and $_.Key -eq 'year.txt') }).Count) { throw "Fiscal year $FiscalYear was closed but has integrity issues; recovery stopped: $(@($check.Issues | ForEach-Object Detail) -join ' ')" } } else { # Closing reseals year.txt, so it must not carry an unsealed edit forward. $sealedYear = (Get-LedgerIntegrityState -Chain (Read-LedgerIntegrityChain -YearDir $YearDir)).Metadata['year.txt'] if ($sealedYear -and $sealedYear -ne (Get-LedgerContentHash -Path $YearFile)) { throw "Fiscal year $FiscalYear cannot be closed: '$YearFile' has been changed since it was sealed. Run Test-LedgerIntegrity and restore it first." } if (Test-Path -LiteralPath (Get-LedgerSieImportPath -JournalPath $JournalPath -FiscalYear $FiscalYear)) { throw "Fiscal year $FiscalYear has an unfinished SIE import. Repeat Import-LedgerSie with the same file to complete it before closing the year." } if ($operation) { if ([bool]$SkipResultEntry -ne $operation.SkipResultEntry) { throw "Fiscal year $FiscalYear has a closing plan $(if ($operation.SkipResultEntry) { 'without' } else { 'with' }) a result entry. Repeat Close-LedgerFiscalYear with the same options to complete it." } if ($operation.VerificationNumber -gt 0 -and (($EquityAccount -and $EquityAccount -ne $operation.EquityAccount) -or ($PSBoundParameters.ContainsKey('ResultAccount') -and $ResultAccount -ne $operation.ResultAccount))) { throw "Fiscal year $FiscalYear has a closing plan booking $($operation.ResultAccount) against $($operation.EquityAccount). Repeat Close-LedgerFiscalYear without other accounts to complete it." } } # The net result of the profit and loss accounts (classes 3-8); negative for a profit. $getResult = { $resultRows = @(Get-LedgerBalance -JournalPath $JournalPath -FiscalYear $FiscalYear | Where-Object { $_.AccountNumber -match '^[3-8]' }) $sum = if ($resultRows) { ($resultRows | Measure-Object -Property Balance -Sum).Sum } else { 0 } [Math]::Round([decimal]$sum, 2) } if (-not $operation) { $operation = [pscustomobject]@{ OperationId = [guid]::NewGuid(); FiscalYear = $FiscalYear; VerificationNumber = 0 SkipResultEntry = [bool]$SkipResultEntry; ResultAmount = [decimal]0 ResultAccount = $null; EquityAccount = $null; VerificationHash = $null } # Book the net result to equity unless explicitly skipped. When the profit # and loss accounts net to zero there is nothing to book, so the year is # simply flagged as closed (this also keeps legacy no-result years working). if (-not $SkipResultEntry) { $operation.ResultAmount = & $getResult if ($operation.ResultAmount -ne 0) { $operation.EquityAccount = if ($EquityAccount) { $EquityAccount } else { Resolve-LedgerEquityAccount -JournalPath $JournalPath } $operation.ResultAccount = $ResultAccount if (-not $EndDate) { throw "Cannot determine the result date - EndDate missing from year.txt in $FiscalYear." } # Debit the result account and credit equity for a profit; the raw # balance of the P&L accounts is negative for a profit, so negating # it gives the debit amount on the result account. $entryParameters = Get-LedgerYearClosingEntry -JournalPath $JournalPath -Operation $operation -Date $EndDate Assert-LedgerInvoicePostingIntegrity -JournalPath $JournalPath -FiscalYear $FiscalYear -Label 'Fiscal year closing' $verificationLines = @(Get-LedgerVerificationLines @entryParameters) $operation.VerificationHash = Get-LedgerSha256Hex -Bytes ([Text.Encoding]::UTF8.GetBytes(($verificationLines -join "`n") + "`n")) $operation.VerificationNumber = Get-LedgerNextVerificationNumber -JournalPath $JournalPath -FiscalYear $FiscalYear } } } elseif (-not $operation.SkipResultEntry) { $current = & $getResult if ($operation.VerificationNumber -gt 0) { if (-not $EndDate) { throw "Cannot determine the result date - EndDate missing from year.txt in $FiscalYear." } $entryParameters = Get-LedgerYearClosingEntry -JournalPath $JournalPath -Operation $operation -Date $EndDate $verificationPath = Join-Path $YearDir ('ver{0:D4}.txt' -f $operation.VerificationNumber) $existingVerification = Test-Path -LiteralPath $verificationPath if ($existingVerification) { Assert-LedgerInvoicePostingVerification -Path $verificationPath -Operation $operation -Label 'fiscal year closing' foreach ($row in $entryParameters.Rows) { if ($row.Account -match '^[3-8]') { $current -= $row.Amount } } } } if ($current -ne $operation.ResultAmount) { throw "The result of fiscal year $FiscalYear has changed since its closing plan was saved ($($operation.ResultAmount) planned, $current now). Recovery stopped; inspect the entries added after the interruption." } if ($operation.VerificationNumber -gt 0) { $unsealedNumber = if ($existingVerification) { $operation.VerificationNumber } else { 0 } Assert-LedgerInvoicePostingIntegrity -JournalPath $JournalPath -FiscalYear $FiscalYear ` -UnsealedVerification $unsealedNumber -Label 'Fiscal year closing' if (-not $existingVerification) { $verificationLines = @(Get-LedgerVerificationLines @entryParameters) if ((Get-LedgerSha256Hex -Bytes ([Text.Encoding]::UTF8.GetBytes(($verificationLines -join "`n") + "`n"))) -ne $operation.VerificationHash) { throw "Fiscal year $FiscalYear no longer produces the saved result verification. Recovery stopped." } } } } } if (-not $PSCmdlet.ShouldProcess($FiscalYear, 'Close fiscal year and book net result')) { return } if (-not (Test-Path -LiteralPath $closingPath)) { Save-LedgerYearClosing -Path $closingPath -Operation $operation } try { if (-not $closed) { if ($operation.VerificationNumber -gt 0) { if (-not $existingVerification) { Write-LedgerVerification @entryParameters -ReservedNumber $operation.VerificationNumber -Confirm:$false } Complete-LedgerInvoicePostingVerification -JournalPath $JournalPath -Operation $operation -Label 'fiscal year closing' } # Rewrite year.txt with updated status. $NewLines = foreach ($Line in $Lines) { if ($Line -match '^Status:\s*') { 'Status: Closed' } else { $Line } } Set-LedgerFileContent -Path $YearFile -Value $NewLines } [void](Protect-LedgerYearContent -YearDir $YearDir -ChangedMetadata 'year.txt' -Closing) # The plan only marks an unfinished close; a completed year needs no reservation. Remove-Item -LiteralPath $closingPath -Force } catch { throw [InvalidOperationException]::new( "Could not confirm completion of closing fiscal year $FiscalYear. Keep '$closingPath'; rerun Close-LedgerFiscalYear -JournalPath '$JournalPath' -FiscalYear '$FiscalYear' to recover safely. $($_.Exception.Message)", $_.Exception) } $integrity = Test-LedgerIntegrity -JournalPath $JournalPath -FiscalYear $FiscalYear if (-not $integrity.IsValid) { Write-Warning "Fiscal year $FiscalYear has integrity issues: $(@($integrity.Issues | ForEach-Object Detail) -join ' ')" } [PSCustomObject]@{ FiscalYear = $FiscalYear ResultVerification = if ($operation.VerificationNumber -gt 0) { $operation.VerificationNumber } else { $null } ChainHash = $integrity.ChainHash } } finally { Exit-LedgerJournalLock -Lock $journalLock } } } |