Public/Add-LedgerCreditInvoice.ps1
|
<# .SYNOPSIS Creates a credit invoice (kreditfaktura) that reverses a posted customer invoice. .DESCRIPTION Creates a new credit-note invoice with negated rows for an existing, posted invoice and books a reversing verification: the receivable account is credited with the gross total, and revenue and VAT are debited back. Booked fees and interest on the invoice are reversed too: their income accounts are debited and the credit note carries the negated charges. Both the original invoice and the new credit note are marked with status 'Credited' so the accounts receivable reconciles to zero for the pair. Only an invoice with status 'Booked' (posted but unpaid) can be credited: - a 'Draft' invoice has no verification to reverse — post it first, or just delete it; - an invoice with payments ('Partial'/'Paid') must have its payments handled first; - an already 'Credited' invoice cannot be credited again. Crediting is safely resumable. Before the verification is written, an immutable plan in invoices/credits/ reserves the credit-note number and verification number. If the command is interrupted, run it again for the same invoice: it completes the saved credit (verification, credit note and status) without creating a second credit note or verification. Other changes to the invoice are blocked until the credit is completed. .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 posted invoice to credit. .PARAMETER Date The date of the credit note and its reversing verification. Defaults to today. When an interrupted credit is resumed, the planned date is used. .PARAMETER FiscalYear Optional. The fiscal year to post the reversal into. If omitted, the fiscal year containing -Date is used. .PARAMETER PassThru If specified, returns the created credit-note invoice object. By default the command produces no output. .EXAMPLE Add-LedgerCreditInvoice -JournalPath .\MinFirma.ledger -InvoiceNumber 1 Credits invoice 1, creating a credit note and a reversing verification such as: 1510 Kundfordringar -12500 3010 Försäljning +10000 2610 Utgående moms +2500 .EXAMPLE Add-LedgerCreditInvoice -InvoiceNumber 5 -Date '2024-04-30' -PassThru Credits invoice 5 as of 30 April 2024 and returns the created credit note. If the call is interrupted, repeating it completes the same credit note. Any booked reminder fee or interest on invoice 5 is reversed as well (e.g. debit 3590). #> function Add-LedgerCreditInvoice { [CmdletBinding(SupportsShouldProcess)] param ( [Parameter()] [string]$JournalPath, [Parameter(Mandatory, ValueFromPipelineByPropertyName)] [int]$InvoiceNumber, [Parameter()] [datetime]$Date = (Get-Date).Date, [Parameter()] [string]$FiscalYear, [Parameter()] [switch]$PassThru ) process { $JournalPath = Resolve-LedgerJournalPath -JournalPath $JournalPath -SchemaCheck Write $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." } $original = Read-LedgerInvoiceFile -Path $filePath if ($original.InvoiceNumber -ne $InvoiceNumber) { throw "Invoice $InvoiceNumber has an inconsistent identity." } $operationPath = Get-LedgerInvoiceCreditPath -JournalPath $JournalPath -InvoiceNumber $InvoiceNumber $operation = if (Test-Path -LiteralPath $operationPath) { Read-LedgerInvoiceCreditOperation -Path $operationPath } else { $null } $creditDate = $Date.Date $creditYear = $FiscalYear if ($operation) { if (($PSBoundParameters.ContainsKey('Date') -and $Date.Date -ne $operation.CreditDate) -or ($FiscalYear -and $FiscalYear -ne $operation.FiscalYear)) { throw "Invoice $InvoiceNumber has a saved credit plan dated $($operation.CreditDate.ToString('yyyy-MM-dd')) in $($operation.FiscalYear). Use its original inputs to retry." } $creditDate = $operation.CreditDate $creditYear = $operation.FiscalYear $creditNotePath = Join-Path $invoiceDir (Get-LedgerInvoiceFileName -InvoiceNumber $operation.CreditInvoiceNumber) if ($original.Status -eq 'Credited') { if (-not (Test-Path -LiteralPath $creditNotePath)) { throw "Invoice $InvoiceNumber is credited, but its planned credit invoice $($operation.CreditInvoiceNumber) is missing. Recovery stopped." } Assert-LedgerCreditNote -CreditNote (Read-LedgerInvoiceFile -Path $creditNotePath) -Original $original -Operation $operation $verificationPath = Join-Path $JournalPath $creditYear ('ver{0:D4}.txt' -f $operation.VerificationNumber) Assert-LedgerInvoicePostingVerification -Path $verificationPath -Operation $operation Assert-LedgerInvoicePostingIntegrity -JournalPath $JournalPath -FiscalYear $creditYear if ($PassThru -and -not $WhatIfPreference) { Get-LedgerInvoice -JournalPath $JournalPath -InvoiceNumber $operation.CreditInvoiceNumber } return } if ((Get-LedgerContentHash -Path $filePath) -ne $operation.InvoiceHash) { throw "Invoice $InvoiceNumber has changed since its credit was planned. Recovery stopped." } } Assert-LedgerNoPendingInvoicePayment -JournalPath $JournalPath -Invoice $original -ExceptCredit switch ($original.Status) { 'Booked' { } 'Draft' { throw "Invoice $InvoiceNumber is not posted. Post it first with Invoke-LedgerInvoicePosting, or delete the draft." } 'Credited' { throw "Invoice $InvoiceNumber has already been credited." } default { throw "Invoice $InvoiceNumber has payments (status '$($original.Status)') and cannot be credited. Handle the payments first." } } # Resolve the fiscal year from the credit-note date unless one was supplied. if (-not $creditYear) { $creditYear = Find-FiscalYearForDate -JournalPath $JournalPath -Date $creditDate if (-not $creditYear) { throw "No fiscal year covers the date $($creditDate.ToString('yyyy-MM-dd')). Create one with New-LedgerFiscalYear or specify -FiscalYear." } } # Build the reversing verification: negate the original posting. $accountSums = [ordered]@{} $addAmount = { param($account, $amount) if ($accountSums.Contains($account)) { $accountSums[$account] += $amount } else { $accountSums[$account] = $amount } } & $addAmount $original.ReceivableAccount (-$original.Total) foreach ($row in $original.Rows) { & $addAmount $row.Account $row.Amount if ($row.VatAmount -ne 0) { & $addAmount $row.VatAccount $row.VatAmount } } foreach ($charge in $original.Charges) { & $addAmount $charge.Account $charge.Amount } $entryRows = foreach ($account in $accountSums.Keys) { @{ Account = $account; Amount = [Math]::Round([decimal]$accountSums[$account], 2) } } $creditNumber = if ($operation) { $operation.CreditInvoiceNumber } else { Get-LedgerNextInvoiceNumber -JournalPath $JournalPath } $creditNotePath = Join-Path $invoiceDir (Get-LedgerInvoiceFileName -InvoiceNumber $creditNumber) $operationId = if ($operation) { $operation.OperationId } else { [guid]::NewGuid() } $entryParameters = @{ JournalPath = $JournalPath; FiscalYear = $creditYear; Date = $creditDate Description = "Kreditfaktura $creditNumber - kreditering av faktura $InvoiceNumber" Rows = @($entryRows); OperationId = $operationId } $existingVerification = $false if ($operation) { $verificationPath = Join-Path $JournalPath $creditYear ('ver{0:D4}.txt' -f $operation.VerificationNumber) $existingVerification = Test-Path -LiteralPath $verificationPath if ($existingVerification) { Assert-LedgerInvoicePostingVerification -Path $verificationPath -Operation $operation } } elseif (Test-Path -LiteralPath $creditNotePath) { throw "Credit invoice number $creditNumber is already in use. Recovery stopped." } $unsealedNumber = if ($existingVerification) { $operation.VerificationNumber } else { 0 } Assert-LedgerInvoicePostingIntegrity -JournalPath $JournalPath -FiscalYear $creditYear -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 "Invoice $InvoiceNumber no longer produces the saved credit verification plan. Recovery stopped." } if (-not $operation) { $operation = [pscustomobject]@{ OperationId = $operationId; InvoiceNumber = $InvoiceNumber; CreditInvoiceNumber = $creditNumber FiscalYear = $creditYear VerificationNumber = Get-LedgerNextVerificationNumber -JournalPath $JournalPath -FiscalYear $creditYear InvoiceHash = Get-LedgerContentHash -Path $filePath; VerificationHash = $hash; CreditDate = $creditDate } } } if (-not $PSCmdlet.ShouldProcess("Invoice $InvoiceNumber", "Create credit invoice $creditNumber")) { return } if (-not (Test-Path -LiteralPath $operationPath)) { Save-LedgerInvoiceCreditOperation -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 crediting. Its saved verification was not removed." } if (Test-Path -LiteralPath $creditNotePath) { Assert-LedgerCreditNote -CreditNote (Read-LedgerInvoiceFile -Path $creditNotePath) -Original $original -Operation $operation } else { # Persist the credit note with negated rows. $creditRows = foreach ($row in $original.Rows) { [PSCustomObject]@{ Account = $row.Account Amount = - [decimal]$row.Amount VatRate = $row.VatRate VatAccount = $row.VatAccount VatAmount = - [decimal]$row.VatAmount Description = $row.Description Quantity = if ($null -ne $row.Quantity) { - [decimal]$row.Quantity } else { $null } Unit = $row.Unit UnitPrice = $row.UnitPrice } } Save-LedgerInvoiceFile -Invoice ([PSCustomObject]@{ InvoiceNumber = $creditNumber CustomerNumber = $original.CustomerNumber InvoiceDate = $creditDate DueDate = $creditDate Description = "Kreditfaktura för faktura $InvoiceNumber" Status = 'Credited' ReceivableAccount = $original.ReceivableAccount BookedVerification = $operation.VerificationNumber BookedFiscalYear = $creditYear Rows = @($creditRows) Payments = @() Charges = @(foreach ($charge in $original.Charges) { [PSCustomObject]@{ Date = $creditDate; Type = $charge.Type; Amount = - [decimal]$charge.Amount Account = $charge.Account; VerificationNumber = $operation.VerificationNumber; FiscalYear = $creditYear } }) FilePath = $creditNotePath }) } # Mark the original as credited so the receivable nets to zero. $original.Status = 'Credited' Save-LedgerInvoiceFile -Invoice $original } catch { throw [InvalidOperationException]::new( "Could not confirm completion of the credit of invoice $InvoiceNumber. Keep '$operationPath' and its reserved numbers; rerun Add-LedgerCreditInvoice -JournalPath '$JournalPath' -InvoiceNumber $InvoiceNumber to recover safely. $($_.Exception.Message)", $_.Exception) } if ($PassThru) { Get-LedgerInvoice -JournalPath $JournalPath -InvoiceNumber $creditNumber } } finally { Exit-LedgerJournalLock -Lock $journalLock } } } |