Private/EntryWriter.ps1

function Get-LedgerEntryAmountState {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]]$Rows
    )

    $sum = [decimal]0
    $firstExtra = $null
    foreach ($row in $Rows) {
        try { $amount = [decimal]$row.Amount }
        catch { throw "Row amount '$($row.Amount)' for account $($row.Account) is not a number." }
        if ($null -eq $firstExtra -and $amount -ne [Math]::Round($amount, 2)) {
            $firstExtra = $row
        }
        $sum += $amount
    }
    [PSCustomObject]@{ Sum = $sum; HasExtraDecimals = $null -ne $firstExtra; FirstExtra = $firstExtra }
}

function Assert-LedgerEntryAmounts {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [hashtable[]]$Rows,

        [switch]$LegacyReversal
    )

    $state = Get-LedgerEntryAmountState -Rows $Rows
    if ($state.HasExtraDecimals -and -not $LegacyReversal) {
        $row = $state.FirstExtra
        throw "Row amount $($row.Amount) for account $($row.Account) has more than two decimals. Round it to whole öre."
    }
    if ($state.Sum -ne 0 -and -not ($LegacyReversal -and [Math]::Round($state.Sum, 2) -eq 0)) {
        throw "Entry does not balance. Sum of rows: $($state.Sum) (must be 0)."
    }
    if ($LegacyReversal -and $state.HasExtraDecimals) {
        Write-Warning "Reversing a legacy verification with more than two decimals exactly (reversal sum: $($state.Sum)). No amounts or original files were rounded or changed."
    }
}

function Assert-LedgerEntryDate {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$YearDir,
        [Parameter(Mandatory)] [string]$FiscalYear,
        [Parameter(Mandatory)] [datetime]$Date
    )

    if (-not (Test-Path -LiteralPath $YearDir -PathType Container)) {
        throw "Fiscal year not found: $FiscalYear"
    }
    $yearFile = Join-Path $YearDir 'year.txt'
    if (-not (Test-Path -LiteralPath $yearFile -PathType Leaf)) {
        throw "Invalid fiscal year - year.txt not found in: $FiscalYear"
    }
    Assert-LedgerFiscalYearOpen -YearDir $YearDir -FiscalYear $FiscalYear -Action 'add entries'
    $start = $null
    $end = $null
    foreach ($line in (Get-Content -LiteralPath $yearFile)) {
        if ($line -match '^StartDate:\s*(.+)$') {
            $parsed = [datetime]::MinValue
            if ([datetime]::TryParse($Matches[1].Trim(), [ref]$parsed)) { $start = $parsed }
        }
        elseif ($line -match '^EndDate:\s*(.+)$') {
            $parsed = [datetime]::MinValue
            if ([datetime]::TryParse($Matches[1].Trim(), [ref]$parsed)) { $end = $parsed }
        }
    }
    if (-not $start -or -not $end) {
        throw "Invalid fiscal year - StartDate/EndDate missing or unparseable in year.txt for $FiscalYear."
    }
    if ($Date -lt $start -or $Date -gt $end) {
        throw "Date $($Date.ToString('yyyy-MM-dd')) is outside fiscal year $FiscalYear ($($start.ToString('yyyy-MM-dd')) to $($end.ToString('yyyy-MM-dd')))."
    }
}

function Get-LedgerVerificationLines {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$JournalPath,
        [Parameter(Mandatory)] [string]$FiscalYear,
        [Parameter(Mandatory)] [datetime]$Date,
        [Parameter(Mandatory)] [AllowEmptyString()] [string]$Description,
        [Parameter(Mandatory)] [hashtable[]]$Rows,
        [string[]]$Attachment,
        [switch]$LegacyReversal,
        [guid]$OperationId = [guid]::Empty
    )

    $YearDir = Join-Path $JournalPath $FiscalYear
    Assert-LedgerEntryDate -YearDir $YearDir -FiscalYear $FiscalYear -Date $Date
    Assert-LedgerEntryAmounts -Rows $Rows -LegacyReversal:$LegacyReversal

    $KontoplanFile = Join-Path $JournalPath 'accounts.txt'
    if (Test-Path $KontoplanFile) {
        $ValidAccounts = @{}
        foreach ($Line in (Get-Content $KontoplanFile)) {
            if ($Line -match '^(\d+)\t') {
                $ValidAccounts[$Matches[1]] = $true
            }
        }
        foreach ($Row in $Rows) {
            if (-not $ValidAccounts.ContainsKey($Row.Account)) {
                throw "Account $($Row.Account) does not exist in chart of accounts."
            }
        }
    }

    if ($Attachment) {
        foreach ($attachmentPath in $Attachment) {
            if (-not (Test-Path $attachmentPath -PathType Leaf)) {
                throw "Attachment file not found: $attachmentPath"
            }
        }
    }

    $Lines = @(
        "Date: $($Date.ToString('yyyy-MM-dd'))"
        "Description: $(ConvertTo-LedgerTextField $Description)"
    )
    if ($OperationId -ne [guid]::Empty) { $Lines += "OperationId: $OperationId" }
    $Lines += ''
    foreach ($Row in $Rows) {
        $line = "$($Row.Account)`t$($Row.Amount)"
        if ($Row.ContainsKey('Objects') -and $Row.Objects -and $Row.Objects.Count -gt 0) {
            foreach ($dimNum in $Row.Objects.Keys) {
                $dim = Get-LedgerDimension -JournalPath $JournalPath -DimensionNumber $dimNum
                if (-not $dim) {
                    throw "Dimension $dimNum does not exist."
                }
                $obj = Get-LedgerObject -JournalPath $JournalPath -DimensionNumber $dimNum -ObjectNumber $Row.Objects[$dimNum]
                if (-not $obj) {
                    throw "Object '$($Row.Objects[$dimNum])' does not exist in dimension $dimNum."
                }
            }
            $line += "`t$(Format-ObjectTag -Objects $Row.Objects)"
        }
        if ($Row.ContainsKey('Comment') -and $null -ne $Row.Comment) {
            $commentText = ([string]$Row.Comment) -replace "[`t`r`n]+", ' '
            $commentText = $commentText.Trim()
            if ($commentText) {
                $line += "`t$commentText"
            }
        }
        $Lines += $line
    }
    $Lines
}

function Get-LedgerNextVerificationNumber {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$JournalPath,
        [Parameter(Mandatory)] [string]$FiscalYear
    )
    $numbers = @(Get-ChildItem -LiteralPath (Join-Path $JournalPath $FiscalYear) -Filter 'ver*.txt' -File -ErrorAction Stop |
            ForEach-Object { if ($_.BaseName -match '^ver(\d+)$') { [int]$Matches[1] } })
    $numbers += @(Get-LedgerInvoicePostingReservation -JournalPath $JournalPath -FiscalYear $FiscalYear)
    $numbers += @(Get-LedgerBankPostingOperation -JournalPath $JournalPath | Where-Object FiscalYear -eq $FiscalYear | ForEach-Object VerificationNumber)
    if ($numbers.Count) { [int]($numbers | Measure-Object -Maximum).Maximum + 1 } else { 1 }
}

function Write-LedgerVerification {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [string]$JournalPath,
        [string]$FiscalYear,
        [Parameter(Mandatory)] [datetime]$Date,
        [Parameter(Mandatory)] [string]$Description,
        [Parameter(Mandatory)] [hashtable[]]$Rows,
        [string[]]$Attachment,
        [switch]$PassThru,
        # Only Add-LedgerReversal uses the old precision rule, on mirrored rows.
        [switch]$LegacyReversal,
        [guid]$OperationId = [guid]::Empty,
        [ValidateRange(0, [int]::MaxValue)] [int]$ReservedNumber = 0
    )

    $Description = ConvertTo-LedgerTextField $Description
    $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
        $Lines = @(Get-LedgerVerificationLines -JournalPath $JournalPath -FiscalYear $FiscalYear `
                -Date $Date -Description $Description -Rows $Rows -Attachment $Attachment `
                -LegacyReversal:$LegacyReversal -OperationId $OperationId)

        if ($ReservedNumber -and $OperationId -eq [guid]::Empty) {
            throw 'A reserved verification number requires an operation identity.'
        }
        $NextNum = if ($ReservedNumber) { $ReservedNumber } else {
            Get-LedgerNextVerificationNumber -JournalPath $JournalPath -FiscalYear $FiscalYear
        }
        $FileName = 'ver' + $NextNum.ToString('0000') + '.txt'
        $FilePath = Join-Path $YearDir $FileName
        if (-not $PSCmdlet.ShouldProcess($FiscalYear, "Add verification $FileName")) {
            return
        }
        Set-LedgerFileContent -Path $FilePath -Value $Lines -NoClobber
        try {
            Register-LedgerIntegrityItem -YearDir $YearDir -Kind Verification -Key "$NextNum" -Path $FilePath

            $attached = @()
            if ($Attachment) {
                $attached = Add-LedgerAttachment -JournalPath $JournalPath -FiscalYear $FiscalYear `
                    -VerificationNumber $NextNum -Path $Attachment -PassThru
            }
        }
        catch {
            throw [System.InvalidOperationException]::new(
                "Verification '$FilePath' was saved, but sealing or attachment registration failed. Do not post it again. Inspect the saved verification and run Test-LedgerIntegrity before taking recovery action. $($_.Exception.Message)",
                $_.Exception)
        }
        if ($PassThru) {
            [PSCustomObject]@{
                VerificationNumber = $NextNum
                FiscalYear         = $FiscalYear
                Date               = $Date
                Description        = $Description
                Path               = $FilePath
                Attachments        = @($attached | ForEach-Object { $_.FileName })
            }
        }
    }
    finally { Exit-LedgerJournalLock -Lock $journalLock }
}