Public/Add-LedgerInvoiceReminder.ps1

<#
.SYNOPSIS
Records a payment reminder (betalningspåminnelse) for an overdue invoice and
optionally writes a reminder document.

.DESCRIPTION
Registers that a reminder has been issued for a posted, unpaid invoice: the
invoice's reminder count is increased by one and the reminder date is recorded on
the invoice. When -Path is given, a reminder document is written in the requested
format (PDF, Word, Markdown or plain text) showing the outstanding amount, how
many days the invoice is overdue, an optional reminder fee and the OCR payment
reference.

The reminder fee is shown on the document as an amount to add to the payment but
is not booked to the ledger, so the accounts receivable continues to reconcile
against the invoice's remaining amount.

Only a posted, unpaid invoice (status Booked or Partial) can be reminded.

.PARAMETER JournalPath
The path to an existing journal directory. If omitted, uses the current journal
set via Set-LedgerCurrentJournal.

.PARAMETER InvoiceNumber
The number of the overdue invoice.

.PARAMETER Date
The reminder date, used to calculate the days overdue. Defaults to today.

.PARAMETER Fee
An optional reminder fee (påminnelseavgift). By default it is only shown on the
document and added to the amount to pay, not booked to the ledger. Combine with
-BookFee to book it as an actual charge on the invoice. Defaults to 0.

.PARAMETER BookFee
Book the reminder fee as an actual charge on the invoice (debit the receivable,
credit -FeeAccount) so it increases the open receivable. Requires -Fee greater
than zero. Equivalent to calling Add-LedgerInvoiceFee.

.PARAMETER FeeAccount
The income account a booked fee is credited to. Defaults to '3590' (Övriga
sidointäkter). Only used together with -BookFee.

.PARAMETER Path
Optional destination path for the reminder document. If omitted, the reminder is
only recorded on the invoice and no document is written.

.PARAMETER Format
The document format when -Path is given: 'Pdf' (default), 'Word', 'Markdown' or
'Text'.

.PARAMETER Force
Overwrite the destination file if it already exists.

.PARAMETER PassThru
If specified, returns the updated invoice object.

.EXAMPLE
Add-LedgerInvoiceReminder -JournalPath .\MinFirma.ledger -InvoiceNumber 1 -Path .\paminnelse-1.pdf

Records a reminder for invoice 1 and writes a PDF reminder.

.EXAMPLE
Add-LedgerInvoiceReminder -InvoiceNumber 1 -Fee 60 -Date '2024-05-15' -Path .\paminnelse-1.docx -Format Word

Records a second reminder with a 60 kr reminder fee and writes a Word document.
.EXAMPLE
Add-LedgerInvoiceReminder -InvoiceNumber 1 -Fee 60 -BookFee -Path .\paminnelse-1.pdf

Records a reminder, books a 60 kr reminder fee on the invoice (debit 1510, credit
3590) and writes a PDF where the fee is part of the outstanding amount.
#>

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

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

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

        [Parameter()]
        [ValidateRange(0, [double]::MaxValue)]
        [decimal]$Fee = 0,

        [Parameter()]
        [switch]$BookFee,

        [Parameter()]
        [string]$FeeAccount = '3590',

        [Parameter()]
        [string]$Path,

        [Parameter()]
        [ValidateSet('Pdf', 'Word', 'Markdown', 'Text')]
        [string]$Format = 'Pdf',

        [Parameter()]
        [switch]$Force,

        [Parameter()]
        [switch]$PassThru
    )
    process {
        $JournalPath = Resolve-LedgerJournalPath -JournalPath $JournalPath -SchemaCheck 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."
        }

        if ($PSBoundParameters.ContainsKey('Path') -and (Test-Path $Path) -and -not $Force) {
            throw "Destination file already exists: $Path. Use -Force to overwrite."
        }

        $invoice = Read-LedgerInvoiceFile -Path $filePath

        if ($invoice.Status -notin @('Booked', 'Partial')) {
            throw "Invoice $InvoiceNumber is not an open receivable (status '$($invoice.Status)'). Only posted, unpaid invoices can be reminded."
        }

        if ($BookFee -and $Fee -le 0) {
            throw "-BookFee requires -Fee to be greater than zero."
        }

        if (-not $PSCmdlet.ShouldProcess("Invoice $InvoiceNumber", 'Record payment reminder')) {
            return
        }

        $invoice.ReminderCount = [int]$invoice.ReminderCount + 1
        $invoice.LastReminderDate = $Date
        Save-LedgerInvoiceFile -Invoice $invoice

        if ($BookFee) {
            Add-LedgerInvoiceChargeInternal -JournalPath $JournalPath -InvoiceNumber $InvoiceNumber `
                -Type 'Fee' -Amount $Fee -Account $FeeAccount -Date $Date
        }

        if ($PSBoundParameters.ContainsKey('Path')) {
            $journal = Get-LedgerJournal -Path $JournalPath
            $customer = Get-LedgerCustomer -JournalPath $JournalPath -CustomerNumber $invoice.CustomerNumber
            $reloaded = Get-LedgerInvoice -JournalPath $JournalPath -InvoiceNumber $InvoiceNumber
            $blocks = @(Build-LedgerReminderBlock -Invoice $reloaded -Journal $journal -Customer $customer -AsOf $Date -Fee $Fee -FeeBooked:$BookFee)

            switch ($Format) {
                'Word' { ConvertTo-LedgerReportDocx -Block $blocks -Path $Path }
                'Pdf' { ConvertTo-LedgerReportPdf -Block $blocks -Path $Path }
                'Markdown' { ConvertTo-LedgerReportMarkdown -Block $blocks | Set-Content -Path $Path -Encoding UTF8 }
                default { ConvertTo-LedgerReportText -Block $blocks | Set-Content -Path $Path -Encoding UTF8 }
            }
        }

        if ($PassThru) {
            Get-LedgerInvoice -JournalPath $JournalPath -InvoiceNumber $InvoiceNumber
        }
    }
}

function Build-LedgerReminderBlock {
    <#
    .SYNOPSIS
    Builds the shared report block model for a payment reminder document.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [psobject]$Invoice,

        [Parameter(Mandatory)]
        [psobject]$Journal,

        [Parameter()]
        [psobject]$Customer,

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

        [Parameter()]
        [decimal]$Fee = 0,

        [Parameter()]
        [switch]$FeeBooked
    )

    $blocks = New-Object System.Collections.Generic.List[object]

    $blocks.Add(@{ Type = 'Title'; Text = $Journal.Name })
    if ($Journal.OrgNumber) {
        $blocks.Add(@{ Type = 'Paragraph'; Text = "Org.nr $($Journal.OrgNumber)" })
    }

    $reminderNo = [int]$Invoice.ReminderCount
    $blocks.Add(@{ Type = 'Heading'; Level = 1; Text = "Betalningspåminnelse" })
    $blocks.Add(@{ Type = 'Paragraph'; Text = "Påminnelse nr $reminderNo" })
    $blocks.Add(@{ Type = 'Paragraph'; Text = "Påminnelsedatum: $($AsOf.ToString('yyyy-MM-dd'))" })

    # Customer
    $blocks.Add(@{ Type = 'Spacer' })
    $custName = if ($Invoice.PSObject.Properties['CustomerName'] -and $Invoice.CustomerName) { $Invoice.CustomerName } elseif ($Customer) { $Customer.Name } else { '' }
    $blocks.Add(@{ Type = 'Heading'; Level = 2; Text = 'Kund' })
    $blocks.Add(@{ Type = 'Paragraph'; Text = "$($Invoice.CustomerNumber) $custName" })

    # Overdue invoice details
    $daysOverdue = [int]([datetime]$AsOf - [datetime]$Invoice.DueDate).TotalDays
    if ($daysOverdue -lt 0) { $daysOverdue = 0 }
    $blocks.Add(@{ Type = 'Spacer' })
    $blocks.Add(@{ Type = 'Paragraph'; Text = "Detta gäller faktura $($Invoice.InvoiceNumber) daterad $($Invoice.InvoiceDate.ToString('yyyy-MM-dd'))." })
    $blocks.Add(@{ Type = 'Paragraph'; Text = "Förfallodatum: $($Invoice.DueDate.ToString('yyyy-MM-dd')) (förfallen med $daysOverdue dagar)" })

    # Amounts
    $blocks.Add(@{ Type = 'Spacer' })
    $blocks.Add(@{ Type = 'Paragraph'; Text = "Kvarstående belopp: $(Format-LedgerAmount -Value $Invoice.RemainingAmount) kr" })
    $amountToPay = [decimal]$Invoice.RemainingAmount
    if ($Fee -gt 0) {
        if ($FeeBooked) {
            # The fee has been booked as a charge and is already included in the
            # remaining amount, so it is shown for information only.
            $blocks.Add(@{ Type = 'Paragraph'; Text = "Varav påminnelseavgift: $(Format-LedgerAmount -Value $Fee) kr" })
        }
        else {
            $blocks.Add(@{ Type = 'Paragraph'; Text = "Påminnelseavgift: $(Format-LedgerAmount -Value $Fee) kr" })
            $amountToPay = [Math]::Round($amountToPay + $Fee, 2)
        }
    }
    $blocks.Add(@{ Type = 'Heading'; Level = 2; Text = "Att betala: $(Format-LedgerAmount -Value $amountToPay) kr" })

    # Payment information
    $payBits = @()
    if ($Journal.Metadata) {
        foreach ($pair in @(@('Bankgiro', 'Bankgiro'), @('Plusgiro', 'Plusgiro'), @('Iban', 'IBAN'), @('Bic', 'BIC'))) {
            $key = $pair[0]; $label = $pair[1]
            if ($Journal.Metadata.Contains($key) -and $Journal.Metadata[$key]) {
                $payBits += "$label $($Journal.Metadata[$key])"
            }
        }
    }
    $blocks.Add(@{ Type = 'Spacer' })
    if ($payBits.Count -gt 0) {
        $blocks.Add(@{ Type = 'Paragraph'; Text = "Betalning: $($payBits -join ' ')" })
    }
    if ($Invoice.OcrReference) {
        $blocks.Add(@{ Type = 'Paragraph'; Text = "OCR-referens: $($Invoice.OcrReference)" })
    }
    $blocks.Add(@{ Type = 'Paragraph'; Text = "Har betalning redan skett ber vi dig bortse från denna påminnelse." })

    $blocks
}