Private/InvoiceCharge.ps1

# Helper for booking a late charge (reminder fee, late-payment compensation or
# late-payment interest) onto a posted customer invoice. A charge debits the
# invoice's receivable account and credits an income account, and is appended to
# the invoice's 'Charges:' section so it increases the invoice total (and thus the
# open receivable) while staying reconciled against the general ledger.
#
# Each charge is a recoverable operation: an immutable plan in
# invoices/charges/<ChargeId>.txt reserves the verification number before the
# verification is written, and repeating the call with the same ChargeId
# completes an interrupted charge without booking it twice.

function Add-LedgerInvoiceChargeInternal {
    <#
    .SYNOPSIS
    Books a late charge on a posted, unpaid invoice and records it on the invoice.

    .DESCRIPTION
    Debits the invoice's receivable account and credits the supplied income
    account with the charge amount (no VAT), then appends the charge to the
    invoice's Charges section. With -Reminder, the reminder count and date are
    updated in the same invoice write. Returns $true when the charge is recorded
    and $false when it was skipped by -WhatIf or -Confirm.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Mandatory)]
        [string]$JournalPath,

        [Parameter(Mandatory)]
        [int]$InvoiceNumber,

        [Parameter(Mandatory)]
        [ValidateSet('Fee', 'Interest')]
        [string]$Type,

        [Parameter()]
        [decimal]$Amount,

        [Parameter()]
        [decimal]$AnnualRate,

        [Parameter()]
        [string]$Account,

        [Parameter()]
        [datetime]$Date = (Get-Date).Date,

        [Parameter()]
        [string]$FiscalYear,

        [Parameter()]
        [ValidateScript({ if ($_ -eq [guid]::Empty) { throw 'ChargeId must not be an empty GUID.' }; $true })]
        [guid]$ChargeId,

        [Parameter()]
        [switch]$Reminder
    )

    $journalLock = $null
    try {
        $journalLock = Enter-LedgerJournalLock -JournalPath $JournalPath
        Assert-LedgerJournalSchema -Path $JournalPath -Write
        $invoiceDir = Get-LedgerInvoiceDirectory -JournalPath $JournalPath
        $filePath = Join-Path $invoiceDir (Get-LedgerInvoiceFileName -InvoiceNumber $InvoiceNumber)
        if (-not (Test-Path $filePath)) {
            throw "Invoice $InvoiceNumber does not exist."
        }

        $invoice = Read-LedgerInvoiceFile -Path $filePath
        if ($invoice.InvoiceNumber -ne $InvoiceNumber) {
            throw "Invoice $InvoiceNumber has an inconsistent identity."
        }
        $id = if ($PSBoundParameters.ContainsKey('ChargeId')) { $ChargeId } else { [guid]::NewGuid() }
        $operationPath = Join-Path $JournalPath 'invoices' 'charges' "$id.txt"
        $operation = if (Test-Path -LiteralPath $operationPath) { Read-LedgerInvoiceChargeOperation -Path $operationPath } else { $null }
        $chargeAmount = $Amount
        $chargeDate = $Date.Date
        $chargeAccount = if ($Account) { $Account } elseif ($Type -eq 'Interest') { '8310' } else { '3590' }
        $chargeYear = $FiscalYear
        if ($operation) {
            if ($operation.InvoiceNumber -ne $InvoiceNumber -or $operation.ChargeType -ne $Type -or
                [bool]$operation.ReminderCount -ne [bool]$Reminder -or
                ($PSBoundParameters.ContainsKey('Amount') -and $Amount -ne $operation.ChargeAmount) -or
                ($PSBoundParameters.ContainsKey('Date') -and $Date.Date -ne $operation.ChargeDate) -or
                ($Account -and $Account -ne $operation.ChargeAccount) -or
                ($FiscalYear -and $FiscalYear -ne $operation.FiscalYear)) {
                throw "ChargeId $id has a saved plan with different charge inputs. Use its original inputs to retry."
            }
            $chargeAmount = $operation.ChargeAmount
            $chargeDate = $operation.ChargeDate
            $chargeAccount = $operation.ChargeAccount
            $chargeYear = $operation.FiscalYear
            if (Get-LedgerRecordedInvoiceCharge -Invoice $invoice -Operation $operation) {
                $verificationPath = Join-Path $JournalPath $chargeYear ('ver{0:D4}.txt' -f $operation.VerificationNumber)
                Assert-LedgerInvoicePostingVerification -Path $verificationPath -Operation $operation
                Assert-LedgerInvoicePostingIntegrity -JournalPath $JournalPath -FiscalYear $chargeYear
                return (-not $WhatIfPreference)
            }
            if ((Get-LedgerContentHash -Path $filePath) -ne $operation.InvoiceHash) {
                throw "Invoice $InvoiceNumber has changed since charge $id was planned. Recovery stopped."
            }
        }
        Assert-LedgerNoPendingInvoicePayment -JournalPath $JournalPath -Invoice $invoice -ExceptChargeId $id

        if ($invoice.Status -eq 'Draft') {
            throw "Invoice $InvoiceNumber has not been posted. Post it first with Invoke-LedgerInvoicePosting."
        }
        if ($invoice.Status -notin @('Booked', 'Partial')) {
            throw "Invoice $InvoiceNumber is not an open receivable (status '$($invoice.Status)'). Charges can only be booked on posted, unpaid invoices."
        }

        if (-not $operation -and -not $PSBoundParameters.ContainsKey('Amount')) {
            if ($Type -ne 'Interest' -or -not $PSBoundParameters.ContainsKey('AnnualRate')) {
                throw "Specify either -AnnualRate (to calculate interest) or -Amount (to book an explicit amount)."
            }
            $daysOverdue = [int]($chargeDate - [datetime]$invoice.DueDate).TotalDays
            if ($daysOverdue -le 0) {
                throw "Invoice $InvoiceNumber is not overdue on $($chargeDate.ToString('yyyy-MM-dd')) (due $($invoice.DueDate.ToString('yyyy-MM-dd'))). No interest to charge."
            }
            $chargeAmount = [Math]::Round([decimal]$invoice.RemainingAmount * $AnnualRate * $daysOverdue / 365, 2)
            if ($chargeAmount -le 0) {
                throw "The calculated interest for invoice $InvoiceNumber is zero. Nothing to book."
            }
        }
        if ($chargeAmount -le 0) {
            throw "Charge amount must be greater than zero."
        }
        if ([Math]::Round($chargeAmount, 2) -ne $chargeAmount) {
            throw "Charge amount $chargeAmount has more than two decimals."
        }
        if ($chargeAccount -notmatch '^\d+$') {
            throw "Invalid charge account '$chargeAccount'."
        }
        $chargeAmount = [decimal]::Parse($chargeAmount.ToString('0.##', [Globalization.CultureInfo]::InvariantCulture),
            [Globalization.CultureInfo]::InvariantCulture)

        if (-not $chargeYear) {
            $chargeYear = Find-FiscalYearForDate -JournalPath $JournalPath -Date $chargeDate
            if (-not $chargeYear) {
                throw "No fiscal year covers the charge date $($chargeDate.ToString('yyyy-MM-dd')). Create one with New-LedgerFiscalYear or specify -FiscalYear."
            }
        }

        $label = if ($Type -eq 'Interest') { 'Dröjsmålsränta' } else { 'Påminnelseavgift' }
        $entryParameters = @{
            JournalPath = $JournalPath; FiscalYear = $chargeYear; Date = $chargeDate
            Description = "$label kundfaktura $InvoiceNumber"; OperationId = $id
            Rows = @(
                @{ Account = $invoice.ReceivableAccount; Amount = $chargeAmount }
                @{ Account = $chargeAccount; Amount = -$chargeAmount }
            )
        }
        $existingVerification = $false
        if ($operation) {
            $verificationPath = Join-Path $JournalPath $chargeYear ('ver{0:D4}.txt' -f $operation.VerificationNumber)
            $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 $chargeYear -UnsealedVerification $unsealedNumber
        if (-not $existingVerification) {
            $lines = @(Get-LedgerVerificationLines @entryParameters)
            $hash = Get-LedgerSha256Hex -Bytes ([Text.Encoding]::UTF8.GetBytes(($lines -join "`n") + "`n"))
            if ($operation -and $hash -ne $operation.VerificationHash) {
                throw "Charge $id no longer produces its saved verification plan. Recovery stopped."
            }
            if (-not $operation) {
                $operation = [pscustomobject]@{
                    OperationId = $id; InvoiceNumber = $InvoiceNumber; FiscalYear = $chargeYear
                    VerificationNumber = Get-LedgerNextVerificationNumber -JournalPath $JournalPath -FiscalYear $chargeYear
                    InvoiceHash = Get-LedgerContentHash -Path $filePath; VerificationHash = $hash
                    ChargeType = $Type; ChargeDate = $chargeDate; ChargeAmount = $chargeAmount; ChargeAccount = $chargeAccount
                    ReminderCount = if ($Reminder) { [int]$invoice.ReminderCount + 1 } else { 0 }
                }
            }
        }

        $action = if ($Reminder) { "Record payment reminder and book fee of $chargeAmount" }
        elseif ($Type -eq 'Interest') { "Book interest of $chargeAmount" }
        else { "Book fee of $chargeAmount" }
        if (-not $PSCmdlet.ShouldProcess("Invoice $InvoiceNumber", "$action (ChargeId $id)")) {
            return $false
        }

        if (-not (Test-Path -LiteralPath $operationPath)) {
            Save-LedgerInvoiceChargeOperation -Path $operationPath -Operation $operation
        }
        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 "Invoice $InvoiceNumber changed during the charge. Its saved verification was not removed."
            }

            $invoice.Charges += [PSCustomObject]@{
                Date               = $chargeDate
                Type               = $Type
                Amount             = $chargeAmount
                Account            = $chargeAccount
                VerificationNumber = $operation.VerificationNumber
                FiscalYear         = $chargeYear
            }
            if ($operation.ReminderCount) {
                $invoice.ReminderCount = $operation.ReminderCount
                $invoice.LastReminderDate = $chargeDate
            }
            Save-LedgerInvoiceFile -Invoice $invoice
        }
        catch {
            throw [InvalidOperationException]::new(
                "Could not confirm completion of charge $id for invoice $InvoiceNumber. Keep '$operationPath' and retry $(Get-LedgerInvoiceChargeCommand -Operation $operation -JournalPath $JournalPath). $($_.Exception.Message)",
                $_.Exception)
        }
        $true
    }
    finally { Exit-LedgerJournalLock -Lock $journalLock }
}