Public/Add-LedgerEntry.ps1

<#
.SYNOPSIS
Creates a new verification (journal entry) in a fiscal year.

.DESCRIPTION
Creates a sequentially numbered verification file (ver0001.txt, ver0002.txt, etc.)
in the specified fiscal year directory. Enforces double-entry bookkeeping by
requiring that the sum of all row amounts equals zero.

.PARAMETER JournalPath
The path to an existing journal directory.

.PARAMETER FiscalYear
The fiscal year identifier (e.g. '2024-01_2024-12'). Must match an existing
fiscal year directory.

.PARAMETER Date
The date of the transaction.

.PARAMETER Description
A description of the transaction.

.PARAMETER Rows
An array of hashtables, each with 'Account' (account number) and 'Amount'
(positive for debit, negative for credit). The sum of all amounts must be zero.
Each row may optionally include a 'Comment' key with free-text describing that
specific row. Rows can be supplied as an array or piped in (for example from
New-LedgerEntryRow); piped rows are collected into a single verification.

.PARAMETER Attachment
One or more paths to files to attach to the newly created verification (e.g. a
receipt or invoice). All files must exist; they are validated before the
verification is written. Delegates to Add-LedgerAttachment.

.PARAMETER PassThru
If specified, returns an object describing the created verification, including
its number, fiscal year, file path and any attached files. By default the
command produces no output.

.EXAMPLE
$rows = @(
    @{ Account = '1910'; Amount = 5000 }
    @{ Account = '3010'; Amount = -5000 }
)
Add-LedgerEntry -JournalPath .\MinFirma.ledger -FiscalYear '2024-01_2024-12' -Date '2024-03-15' -Description 'Kontantförsäljning' -Rows $rows

Records a cash sale: debit 1910 (Kassa), credit 3010 (Försäljning).

.EXAMPLE
$rows = @(
    @{ Account = '5010'; Amount = 8000 }
    @{ Account = '2640'; Amount = 2000 }
    @{ Account = '2440'; Amount = -10000 }
)
Add-LedgerEntry -JournalPath .\MinFirma.ledger -FiscalYear '2024-01_2024-12' -Date '2024-03-20' -Description 'Hyra kontor' -Rows $rows -Attachment .\hyresfaktura.pdf, .\betalbevis.pdf -PassThru

Records a supplier invoice for office rent (8000 plus 25 % VAT): debit 5010
(Lokalhyra) and 2640 (Ingående moms), credit 2440 (Leverantörsskulder). Attaches
two files to the verification and returns the created verification object.

.EXAMPLE
@(
    New-LedgerEntryRow -Debit '1910' 5000
    New-LedgerEntryRow -Credit '3010' 5000
) |
    Add-LedgerEntry -JournalPath .\MinFirma.ledger -FiscalYear '2024-01_2024-12' -Date '2024-03-15' -Description 'Kontantförsäljning'

Builds rows with New-LedgerEntryRow and pipes them straight into Add-LedgerEntry,
avoiding the signed-amount hashtable syntax.
#>

function Add-LedgerEntry {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter()]
        [string]$JournalPath,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('Name')]
        [string]$FiscalYear,

        [Parameter(Mandatory)]
        [datetime]$Date,

        [Parameter(Mandatory)]
        [string]$Description,

        [Parameter(Mandatory, ValueFromPipeline)]
        [hashtable[]]$Rows,

        [Parameter()]
        [string[]]$Attachment,

        [Parameter()]
        [switch]$PassThru
    )
    begin {
        # Rows supplied on the command line are present in $PSBoundParameters
        # during begin; rows arriving via the pipeline are not yet bound, so this
        # flag distinguishes "New-LedgerEntryRow | Add-LedgerEntry" (accumulate
        # into one verification) from the parameter/fiscal-year-pipeline usage.
        $RowsFromPipeline = -not $PSBoundParameters.ContainsKey('Rows')
        $RowBuffer = [System.Collections.Generic.List[hashtable]]::new()

        $WriteEntry = {
            param([hashtable[]]$EntryRows)

            $Description = ConvertTo-LedgerTextField $Description
            $JournalPath = Resolve-LedgerJournalPath -JournalPath $JournalPath -SchemaCheck 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"
            }

            # Check if fiscal year is closed and capture its date range. year.txt
            # must exist and carry a parseable StartDate/EndDate — otherwise we
            # fail closed rather than silently skipping the date-range check.
            $YearFile = Join-Path $YearDir 'year.txt'
            if (-not (Test-Path $YearFile -PathType Leaf)) {
                throw "Invalid fiscal year - year.txt not found in: $FiscalYear"
            }
            $YearStartDate = $null
            $YearEndDate = $null
            foreach ($Line in (Get-Content $YearFile)) {
                if ($Line -match '^Status:\s*Closed') {
                    throw "Fiscal year $FiscalYear is Closed. Cannot add entries."
                }
                elseif ($Line -match '^StartDate:\s*(.+)$') {
                    $parsed = [datetime]::MinValue
                    if ([datetime]::TryParse($Matches[1].Trim(), [ref]$parsed)) {
                        $YearStartDate = $parsed
                    }
                }
                elseif ($Line -match '^EndDate:\s*(.+)$') {
                    $parsed = [datetime]::MinValue
                    if ([datetime]::TryParse($Matches[1].Trim(), [ref]$parsed)) {
                        $YearEndDate = $parsed
                    }
                }
            }

            if (-not $YearStartDate -or -not $YearEndDate) {
                throw "Invalid fiscal year - StartDate/EndDate missing or unparseable in year.txt for $FiscalYear."
            }

            # Validate date within fiscal year range
            if ($Date -lt $YearStartDate -or $Date -gt $YearEndDate) {
                throw "Date $($Date.ToString('yyyy-MM-dd')) is outside fiscal year $FiscalYear ($($YearStartDate.ToString('yyyy-MM-dd')) to $($YearEndDate.ToString('yyyy-MM-dd')))."
            }

            # Validate balance (round to avoid floating-point accumulation errors)
            $Sum = ($EntryRows | ForEach-Object { $_.Amount }) | Measure-Object -Sum | Select-Object -ExpandProperty Sum
            if ([Math]::Round($Sum, 2) -ne 0) {
                throw "Entry does not balance. Sum of rows: $Sum (must be 0)."
            }

            # Validate accounts against chart of accounts (if it exists)
            $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 $EntryRows) {
                    if (-not $ValidAccounts.ContainsKey($Row.Account)) {
                        throw "Account $($Row.Account) does not exist in chart of accounts."
                    }
                }
            }

            # Validate attachment files exist before writing the verification
            if ($Attachment) {
                foreach ($attachmentPath in $Attachment) {
                    if (-not (Test-Path $attachmentPath -PathType Leaf)) {
                        throw "Attachment file not found: $attachmentPath"
                    }
                }
            }

            # Determine next verification number by scanning existing files
            $ExistingFiles = Get-ChildItem -Path $YearDir -Filter 'ver*.txt' -File -ErrorAction SilentlyContinue
            if ($ExistingFiles) {
                $MaxNum = $ExistingFiles |
                    ForEach-Object { if ($_.BaseName -match '^ver(\d+)$') { [int]$Matches[1] } } |
                    Measure-Object -Maximum |
                    Select-Object -ExpandProperty Maximum
                $NextNum = $MaxNum + 1
            }
            else {
                $NextNum = 1
            }

            $FileName = 'ver' + $NextNum.ToString('0000') + '.txt'
            $FilePath = Join-Path $YearDir $FileName

            # Build file content
            $Lines = @(
                "Date: $($Date.ToString('yyyy-MM-dd'))"
                "Description: $Description"
                ""
            )

            foreach ($Row in $EntryRows) {
                $line = "$($Row.Account)`t$($Row.Amount)"
                if ($Row.ContainsKey('Objects') -and $Row.Objects -and $Row.Objects.Count -gt 0) {
                    # Validate dimension and object references
                    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
            }

            if (-not $PSCmdlet.ShouldProcess($FiscalYear, "Add verification $FileName")) {
                return
            }

            Set-LedgerFileContent -Path $FilePath -Value $Lines
            Register-LedgerIntegrityItem -YearDir $YearDir -Kind Verification -Key "$NextNum" -Path $FilePath

            # Attach any supplied files to the new verification
            $attached = @()
            if ($Attachment) {
                $attached = Add-LedgerAttachment -JournalPath $JournalPath -FiscalYear $FiscalYear `
                    -VerificationNumber $NextNum -Path $Attachment -PassThru
            }

            if ($PassThru) {
                [PSCustomObject]@{
                    VerificationNumber = $NextNum
                    FiscalYear         = $FiscalYear
                    Date               = $Date
                    Description        = $Description
                    Path               = $FilePath
                    Attachments        = @($attached | ForEach-Object { $_.FileName })
                }
            }
        }
    }
    process {
        if ($RowsFromPipeline) {
            foreach ($Row in $Rows) {
                $RowBuffer.Add($Row)
            }
        }
        else {
            & $WriteEntry $Rows
        }
    }
    end {
        if ($RowsFromPipeline) {
            & $WriteEntry $RowBuffer.ToArray()
        }
    }
}