Private/InvoicePosting.ps1

function Get-LedgerInvoicePostingPath {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$JournalPath,
        [Parameter(Mandatory)] [int]$InvoiceNumber,
        [switch]$Supplier
    )
    if ($Supplier) {
        return Join-Path $JournalPath 'supplierinvoices' 'posting' (Get-LedgerSupplierInvoiceFileName -InvoiceNumber $InvoiceNumber)
    }
    Join-Path $JournalPath 'invoices' 'posting' (Get-LedgerInvoiceFileName -InvoiceNumber $InvoiceNumber)
}

function Read-LedgerInvoicePosting {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$Path,
        [switch]$Payment,
        [switch]$Supplier
    )

    $fields = @{}
    foreach ($line in Get-Content -LiteralPath $Path -Encoding UTF8 -ErrorAction Stop) {
        if ([string]::IsNullOrWhiteSpace($line) -or $line.StartsWith(';')) { continue }
        if ($line -notmatch '^([A-Za-z]+):\t(.+)$' -or $fields.ContainsKey($Matches[1])) {
            throw "Invalid invoice posting record '$Path': malformed or duplicate field."
        }
        $fields[$Matches[1]] = $Matches[2]
    }
    $id = [guid]::Empty
    $invoiceNumber = 0
    $verificationNumber = 0
    if ($fields.FormatVersion -ne '1' -or
        -not [guid]::TryParseExact([string]$fields.OperationId, 'D', [ref]$id) -or $id -eq [guid]::Empty -or
        -not [int]::TryParse([string]$fields.InvoiceNumber, [ref]$invoiceNumber) -or $invoiceNumber -le 0 -or
        -not [int]::TryParse([string]$fields.VerificationNumber, [ref]$verificationNumber) -or $verificationNumber -le 0 -or
        [string]::IsNullOrWhiteSpace($fields.FiscalYear) -or
        $fields.InvoiceHash -cnotmatch '^[0-9a-f]{64}$' -or $fields.VerificationHash -cnotmatch '^[0-9a-f]{64}$') {
        throw "Invalid invoice posting record '$Path': missing or invalid identity, reservation or hash."
    }
    $null = Assert-LedgerSafeLeafName -Name $fields.FiscalYear -Kind 'fiscal year'
    $fileName = if ($Payment) { "$id.txt" }
    elseif ($Supplier) { Get-LedgerSupplierInvoiceFileName -InvoiceNumber $invoiceNumber }
    else { Get-LedgerInvoiceFileName -InvoiceNumber $invoiceNumber }
    if (-not $Payment -and (($Supplier -and $fields.InvoiceType -ne 'Supplier') -or
            (-not $Supplier -and $fields.ContainsKey('InvoiceType') -and $fields.InvoiceType -ne 'Customer'))) {
        throw "Invalid invoice posting record '$Path': invoice type does not match its posting directory."
    }
    if ((Split-Path $Path -Leaf) -ne $fileName) {
        throw "Invalid invoice posting record '$Path': identity does not match its filename."
    }
    $operation = [pscustomobject]@{
        OperationId = $id
        InvoiceNumber = $invoiceNumber
        FiscalYear = $fields.FiscalYear
        VerificationNumber = $verificationNumber
        InvoiceHash = $fields.InvoiceHash
        VerificationHash = $fields.VerificationHash
    }
    if ($Payment) {
        $invoiceType = if ($Supplier) { 'Supplier' } else { 'Customer' }
        if (($Supplier -and $fields.InvoiceType -ne 'Supplier') -or
            ($fields.ContainsKey('InvoiceType') -and $fields.InvoiceType -ne $invoiceType)) {
            throw "Invalid invoice posting record '$Path': invoice type does not match its payment directory."
        }
        $date = [datetime]::MinValue
        $amount = [decimal]0
        if (-not [datetime]::TryParseExact([string]$fields.PaymentDate, 'yyyy-MM-dd',
                [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::None, [ref]$date) -or
            $fields.PaymentAmount -notmatch '^\d+(\.\d{1,2})?$' -or
            -not [decimal]::TryParse([string]$fields.PaymentAmount, [Globalization.NumberStyles]::AllowDecimalPoint,
                [Globalization.CultureInfo]::InvariantCulture, [ref]$amount) -or $amount -le 0 -or
            $fields.PaymentAccount -notmatch '^\d+$') {
            throw "Invalid invoice posting record '$Path': invalid payment date, amount or account."
        }
        $operation | Add-Member -NotePropertyMembers @{
            PaymentDate = $date; PaymentAmount = $amount; PaymentAccount = $fields.PaymentAccount; InvoiceType = $invoiceType
        }
        if ($fields.ContainsKey('BankTransactionId') -or $fields.ContainsKey('BankStatementNumber') -or $fields.ContainsKey('BankTransactionHash')) {
            $transactionId = 0
            $statementNumber = 0
            if (-not [int]::TryParse([string]$fields.BankTransactionId, [ref]$transactionId) -or $transactionId -le 0 -or
                -not [int]::TryParse([string]$fields.BankStatementNumber, [ref]$statementNumber) -or $statementNumber -le 0 -or
                $fields.BankTransactionHash -cnotmatch '^[0-9a-f]{64}$') {
                throw "Invalid invoice posting record '$Path': invalid bank transaction identity or hash."
            }
            $operation | Add-Member -NotePropertyMembers @{
                BankTransactionId = $transactionId; BankStatementNumber = $statementNumber; BankTransactionHash = $fields.BankTransactionHash
            }
        }
    }
    $operation
}

function Get-LedgerInvoicePostingReservation {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$JournalPath,
        [Parameter(Mandatory)] [string]$FiscalYear
    )
    foreach ($store in @(
            @{ Directory = 'invoices'; Filter = 'inv*.txt'; Supplier = $false }
            @{ Directory = 'supplierinvoices'; Filter = 'sup*.txt'; Supplier = $true }
        )) {
        $directory = Join-Path $JournalPath $store.Directory 'posting'
        if (-not (Test-Path -LiteralPath $directory)) { continue }
        foreach ($file in Get-ChildItem -LiteralPath $directory -Filter $store.Filter -File -ErrorAction Stop) {
            $operation = Read-LedgerInvoicePosting -Path $file.FullName -Supplier:$store.Supplier
            if ($operation.FiscalYear -eq $FiscalYear) { $operation.VerificationNumber }
        }
    }
    foreach ($operation in @(Get-LedgerInvoicePaymentOperation -JournalPath $JournalPath) +
        @(Get-LedgerInvoiceChargeOperation -JournalPath $JournalPath) +
        @(Get-LedgerInvoiceCreditOperation -JournalPath $JournalPath) +
        @(Get-LedgerPayslipPostingOperation -JournalPath $JournalPath)) {
        if ($operation.FiscalYear -eq $FiscalYear) { $operation.VerificationNumber }
    }
    $closingPath = Get-LedgerYearClosingPath -JournalPath $JournalPath -FiscalYear $FiscalYear
    if (Test-Path -LiteralPath $closingPath) {
        $closing = Read-LedgerYearClosing -Path $closingPath
        if ($closing.VerificationNumber -gt 0) { $closing.VerificationNumber }
    }
    $importPath = Get-LedgerSieImportPath -JournalPath $JournalPath -FiscalYear $FiscalYear
    if (Test-Path -LiteralPath $importPath) {
        $import = Read-LedgerSieImport -Path $importPath
        if ($import.VerificationCount -gt 0) { $import.FirstVerification + $import.VerificationCount - 1 }
    }
}

function Save-LedgerInvoicePosting {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$Path,
        [Parameter(Mandatory)] [psobject]$Operation,
        [switch]$Payment,
        [switch]$Supplier
    )
    $null = New-Item -ItemType Directory -Path (Split-Path $Path -Parent) -Force -ErrorAction Stop
    $header = if ($Payment -and $Operation.InvoiceType -eq 'Supplier') { '; PSLedger supplier invoice payment' }
    elseif ($Supplier) { '; PSLedger supplier invoice posting' }
    else { '; PSLedger customer invoice posting' }
    $lines = @(
        $header
        "FormatVersion:`t1"
        "OperationId:`t$($Operation.OperationId)"
        "InvoiceNumber:`t$($Operation.InvoiceNumber)"
        "FiscalYear:`t$($Operation.FiscalYear)"
        "VerificationNumber:`t$($Operation.VerificationNumber)"
        "InvoiceHash:`t$($Operation.InvoiceHash)"
        "VerificationHash:`t$($Operation.VerificationHash)"
    )
    if ($Supplier -and -not $Payment) { $lines += "InvoiceType:`tSupplier" }
    if ($Payment) {
        if ($Operation.InvoiceType -eq 'Supplier') { $lines += "InvoiceType:`tSupplier" }
        $lines += @(
            "PaymentDate:`t$($Operation.PaymentDate.ToString('yyyy-MM-dd'))"
            "PaymentAmount:`t$(Format-LedgerInvoiceAmount -Value $Operation.PaymentAmount)"
            "PaymentAccount:`t$($Operation.PaymentAccount)"
        )
        if ($Operation.PSObject.Properties['BankTransactionId']) {
            $lines += @(
                "BankTransactionId:`t$($Operation.BankTransactionId)"
                "BankStatementNumber:`t$($Operation.BankStatementNumber)"
                "BankTransactionHash:`t$($Operation.BankTransactionHash)"
            )
        }
    }
    Set-LedgerFileContent -Path $Path -NoClobber -Value $lines
}

function Assert-LedgerInvoicePostingIntegrity {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$JournalPath,
        [Parameter(Mandatory)] [string]$FiscalYear,
        [int]$UnsealedVerification = 0,
        [string]$Label = 'Invoice posting'
    )
    $check = Test-LedgerIntegrity -JournalPath $JournalPath -FiscalYear $FiscalYear
    $issues = @($check.Issues | Where-Object {
        -not ($_.Kind -eq 'Verification' -and $_.Problem -eq 'Unsealed' -and $_.Key -eq "$UnsealedVerification")
    })
    if ($issues.Count) {
        throw "$Label cannot continue because of integrity issues. Run Test-LedgerIntegrity and resolve them first: $($issues.Detail -join ' ')"
    }
}

function Assert-LedgerInvoicePostingVerification {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$Path,
        [Parameter(Mandatory)] [psobject]$Operation,
        [string]$Label = 'invoice posting'
    )
    if ((Get-LedgerContentHash -Path $Path) -ne $Operation.VerificationHash -or
        "OperationId: $($Operation.OperationId)" -notin @(Get-Content -LiteralPath $Path -Encoding UTF8 -ErrorAction Stop)) {
        throw "Saved verification '$Path' differs from the $Label plan. Recovery stopped without rewriting it."
    }
}

function Complete-LedgerInvoicePostingVerification {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)] [string]$JournalPath,
        [Parameter(Mandatory)] [psobject]$Operation,
        [string]$Label = 'invoice posting'
    )
    $yearDir = Join-Path $JournalPath $Operation.FiscalYear
    $path = Join-Path $yearDir ('ver' + $Operation.VerificationNumber.ToString('0000') + '.txt')
    Assert-LedgerInvoicePostingVerification -Path $path -Operation $Operation -Label $Label
    Assert-LedgerInvoicePostingIntegrity -JournalPath $JournalPath -FiscalYear $Operation.FiscalYear `
        -UnsealedVerification $Operation.VerificationNumber -Label ($Label.Substring(0, 1).ToUpperInvariant() + $Label.Substring(1))
    $chain = Read-LedgerIntegrityChain -YearDir $yearDir
    $state = Get-LedgerIntegrityState -Chain $chain
    if (-not $state.Verifications.Contains("$($Operation.VerificationNumber)")) {
        Register-LedgerIntegrityItem -YearDir $yearDir -Kind Verification -Key "$($Operation.VerificationNumber)" -Path $path
    }
}
function Invoke-LedgerInvoicePostingOperation {
    # Shared, resumable posting of a draft customer or supplier invoice. The caller
    # holds the journal lock. Returns $true when the posting completed, $false on WhatIf.
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Mandatory)] [string]$JournalPath,
        [Parameter(Mandatory)] [int]$InvoiceNumber,
        [string]$FiscalYear,
        [switch]$Supplier
    )
    if ($Supplier) {
        $label = 'Supplier invoice'
        $commandName = 'Invoke-LedgerSupplierInvoicePosting'
        $filePath = Join-Path (Get-LedgerSupplierInvoiceDirectory -JournalPath $JournalPath) (Get-LedgerSupplierInvoiceFileName -InvoiceNumber $InvoiceNumber)
    }
    else {
        $label = 'Invoice'
        $commandName = 'Invoke-LedgerInvoicePosting'
        $filePath = Join-Path (Get-LedgerInvoiceDirectory -JournalPath $JournalPath) (Get-LedgerInvoiceFileName -InvoiceNumber $InvoiceNumber)
    }
    $lowerLabel = $label.ToLowerInvariant()
    if (-not (Test-Path $filePath)) {
        throw "$label $InvoiceNumber does not exist."
    }

    $invoice = if ($Supplier) { Read-LedgerSupplierInvoiceFile -Path $filePath } else { Read-LedgerInvoiceFile -Path $filePath }

    if ($invoice.Status -ne 'Draft') {
        throw "$label $InvoiceNumber is already posted (status '$($invoice.Status)', verification $($invoice.BookedVerification))."
    }
    if ($invoice.InvoiceNumber -ne $InvoiceNumber -or $invoice.BookedVerification -or $invoice.BookedFiscalYear) {
        throw "Draft $lowerLabel $InvoiceNumber has inconsistent identity or posting references. Inspect it before posting."
    }

    $operationPath = Get-LedgerInvoicePostingPath -JournalPath $JournalPath -InvoiceNumber $InvoiceNumber -Supplier:$Supplier
    $operation = if (Test-Path -LiteralPath $operationPath) { Read-LedgerInvoicePosting -Path $operationPath -Supplier:$Supplier } else { $null }
    $invoiceHash = Get-LedgerContentHash -Path $filePath
    $postingYear = $FiscalYear
    if ($operation) {
        if ($invoiceHash -ne $operation.InvoiceHash) {
            throw "$label $InvoiceNumber has changed since its posting plan was saved. Recovery stopped without modifying the invoice or verification."
        }
        if ($postingYear -and $postingYear -ne $operation.FiscalYear) {
            throw "$label $InvoiceNumber has a posting plan for $($operation.FiscalYear), not $postingYear."
        }
        $postingYear = $operation.FiscalYear
    }

    # Resolve the fiscal year from the invoice date unless one was supplied.
    if (-not $postingYear) {
        $postingYear = Find-FiscalYearForDate -JournalPath $JournalPath -Date $invoice.InvoiceDate
        if (-not $postingYear) {
            throw "No fiscal year covers the invoice date $($invoice.InvoiceDate.ToString('yyyy-MM-dd')). Create one with New-LedgerFiscalYear or specify -FiscalYear."
        }
    }

    # Aggregate amounts per account so the verification is compact.
    $accountSums = [ordered]@{}
    $addAmount = {
        param($account, $amount)
        if ($accountSums.Contains($account)) {
            $accountSums[$account] += $amount
        }
        else {
            $accountSums[$account] = $amount
        }
    }

    if ($Supplier) {
        # Debit cost accounts (net) and input VAT, then credit the payable with the gross total.
        foreach ($row in $invoice.Rows) {
            & $addAmount $row.Account $row.Amount
            if ($row.VatAmount -ne 0) {
                & $addAmount $row.VatAccount $row.VatAmount
            }
        }
        & $addAmount $invoice.PayableAccount (-$invoice.Total)
        $description = "Leverantörsfaktura $InvoiceNumber - $($invoice.Description)"
    }
    else {
        # Debit the receivable with the gross total, then credit revenue (net) and VAT.
        & $addAmount $invoice.ReceivableAccount $invoice.Total
        foreach ($row in $invoice.Rows) {
            & $addAmount $row.Account (-$row.Amount)
            if ($row.VatAmount -ne 0) {
                & $addAmount $row.VatAccount (-$row.VatAmount)
            }
        }
        $description = "Kundfaktura $InvoiceNumber - $($invoice.Description)"
    }

    $entryRows = foreach ($account in $accountSums.Keys) {
        @{ Account = $account; Amount = [Math]::Round([decimal]$accountSums[$account], 2) }
    }

    $operationId = if ($operation) { $operation.OperationId } else { [guid]::NewGuid() }
    $entryParameters = @{
        JournalPath = $JournalPath; FiscalYear = $postingYear; Date = $invoice.InvoiceDate
        Description = $description; Rows = @($entryRows); OperationId = $operationId
    }
    $existingVerification = $false
    if ($operation) {
        $verificationPath = Join-Path $JournalPath $postingYear ('ver' + $operation.VerificationNumber.ToString('0000') + '.txt')
        $existingVerification = Test-Path -LiteralPath $verificationPath
        if ($existingVerification) {
            Assert-LedgerInvoicePostingVerification -Path $verificationPath -Operation $operation
        }
    }
    $unsealedNumber = if ($existingVerification) { $operation.VerificationNumber } else { 0 }
    Assert-LedgerInvoicePostingIntegrity -JournalPath $JournalPath -FiscalYear $postingYear -UnsealedVerification $unsealedNumber
    if (-not $existingVerification) {
        $lines = @(Get-LedgerVerificationLines @entryParameters)
        $verificationHash = Get-LedgerSha256Hex -Bytes ([Text.Encoding]::UTF8.GetBytes(($lines -join "`n") + "`n"))
        if ($operation -and $verificationHash -ne $operation.VerificationHash) {
            throw "$label $InvoiceNumber no longer produces the saved verification plan. Recovery stopped."
        }
        if (-not $operation) {
            $operation = [pscustomobject]@{
                OperationId = $operationId
                InvoiceNumber = $InvoiceNumber
                FiscalYear = $postingYear
                VerificationNumber = Get-LedgerNextVerificationNumber -JournalPath $JournalPath -FiscalYear $postingYear
                InvoiceHash = $invoiceHash
                VerificationHash = $verificationHash
            }
        }
    }

    if (-not $PSCmdlet.ShouldProcess("$label $InvoiceNumber", "Post to fiscal year $postingYear")) {
        return $false
    }

    if (-not (Test-Path -LiteralPath $operationPath)) {
        Save-LedgerInvoicePosting -Path $operationPath -Operation $operation -Supplier:$Supplier
    }
    try {
        if (-not $existingVerification) {
            Write-LedgerVerification @entryParameters -ReservedNumber $operation.VerificationNumber -Confirm:$false
        }
        Complete-LedgerInvoicePostingVerification -JournalPath $JournalPath -Operation $operation
        if ((Get-LedgerContentHash -Path $filePath) -ne $operation.InvoiceHash) {
            throw "$label $InvoiceNumber changed during posting. Its saved verification was not removed."
        }

        $invoice.Status = 'Booked'
        $invoice.BookedVerification = $operation.VerificationNumber
        $invoice.BookedFiscalYear = $postingYear
        if ($Supplier) { Save-LedgerSupplierInvoiceFile -Invoice $invoice } else { Save-LedgerInvoiceFile -Invoice $invoice }
    }
    catch {
        throw [System.InvalidOperationException]::new(
            "Could not confirm completion of $lowerLabel $InvoiceNumber posting. Keep '$operationPath' and its reserved verification; rerun $commandName for this invoice to recover safely. $($_.Exception.Message)",
            $_.Exception)
    }
    $true
}