Public/New-LedgerInvoice.ps1
|
<# .SYNOPSIS Creates a new customer invoice (kundfaktura) in the journal. .DESCRIPTION Creates a sequentially numbered invoice file (inv0001.txt, inv0002.txt, etc.) in the journal's 'invoices/' directory. An invoice records what is billed to a customer but is not yet posted to the ledger — use Invoke-LedgerInvoicePosting to create the bookkeeping verification, and Add-LedgerInvoicePayment to register payments. Invoices live at the journal level (not inside a fiscal year) because an invoice may be paid in a later fiscal year than the one it was issued in. Each row describes a revenue line with a net amount (excluding VAT), an optional VAT rate and the account VAT is booked to. The gross invoice total is the sum of all net amounts plus their VAT. .PARAMETER JournalPath The path to an existing journal directory. If omitted, uses the current journal set via Set-LedgerCurrentJournal. .PARAMETER CustomerNumber The number of the customer being invoiced. Must exist in the customer register (see Add-LedgerCustomer). .PARAMETER Date The invoice date. Defaults to today. .PARAMETER DueDate The date the invoice is due. If omitted, it is calculated from the invoice date plus the customer's default payment terms. .PARAMETER Description A description of what is being invoiced. .PARAMETER Rows An array of hashtables describing the revenue rows. Each row must have: - Account: the revenue account (e.g. '3010') - Amount: the net amount excluding VAT (positive). May be omitted when Quantity and UnitPrice are given; it is then Quantity x UnitPrice rounded to öre. When all three are given they must agree. and may optionally have: - VatRate: the VAT rate as a decimal (e.g. 0.25 for 25%); defaults to 0 - VatAccount: the account output VAT is booked to (e.g. '2610'); required when VatRate is greater than 0 - Description: the text shown on the invoice (e.g. 'Systemutveckling mars') - Quantity: the number of units (e.g. 32.5) - Unit: the unit (e.g. 'h', 'st') - UnitPrice: the net price per unit excluding VAT (à-pris) Rows without a Description are shown with the account name on the exported invoice. .PARAMETER ReceivableAccount The accounts-receivable account the invoice total is booked to when posted. Defaults to '1510' (Kundfordringar). .PARAMETER PassThru If specified, returns the created invoice object. By default the command produces no output. .EXAMPLE $rows = @( @{ Account = '3010'; Amount = 10000; VatRate = 0.25; VatAccount = '2610' } ) New-LedgerInvoice -JournalPath .\MinFirma.ledger -CustomerNumber '10' -Date '2024-03-15' -Description 'Konsultarvode mars' -Rows $rows Creates an invoice for 10 000 kr plus 25% VAT to customer 10. .EXAMPLE $rows = @( @{ Account = '3010'; Amount = 8000; VatRate = 0.25; VatAccount = '2610' } @{ Account = '3590'; Amount = 500; VatRate = 0; VatAccount = '' } ) New-LedgerInvoice -JournalPath .\MinFirma.ledger -CustomerNumber 'K012' -Description 'Konsultarvode och utlägg' -DueDate '2024-05-01' -Rows $rows -PassThru Creates an invoice with a VAT-liable service row and a VAT-free expense row, an explicit due date, and returns the created invoice object. .EXAMPLE $rows = @( @{ Account = '3010'; Description = 'Systemutveckling mars'; Quantity = 32.5; Unit = 'h'; UnitPrice = 1100; VatRate = 0.25; VatAccount = '2610' } @{ Account = '3010'; Description = 'Projektledning mars'; Quantity = 6; Unit = 'h'; UnitPrice = 1250; VatRate = 0.25; VatAccount = '2610' } ) New-LedgerInvoice -CustomerNumber '10' -Date '2024-03-31' -Description 'Konsulttjänster mars 2024' -Rows $rows Invoices hours with text, quantity and unit price to Volvo AB. The net amounts (35 750 kr and 7 500 kr) are computed from quantity x unit price. #> function New-LedgerInvoice { [OutputType([pscustomobject])] [CmdletBinding(SupportsShouldProcess)] param ( [Parameter()] [string]$JournalPath, [Parameter(Mandatory)] [string]$CustomerNumber, [Parameter()] [datetime]$Date = (Get-Date).Date, [Parameter()] [datetime]$DueDate, [Parameter(Mandatory)] [string]$Description, [Parameter(Mandatory)] [hashtable[]]$Rows, [Parameter()] [string]$ReceivableAccount = '1510', [Parameter()] [switch]$PassThru ) $JournalPath = Resolve-LedgerJournalPath -JournalPath $JournalPath -SchemaCheck Write if (-not (Test-Path $JournalPath -PathType Container)) { throw "Journal not found: $JournalPath" } # Validate the customer exists. $customer = Get-LedgerCustomer -JournalPath $JournalPath -CustomerNumber $CustomerNumber if (-not $customer) { throw "Customer '$CustomerNumber' does not exist. Add it with Add-LedgerCustomer first." } # Validate and normalise rows. if ($Rows.Count -eq 0) { throw "An invoice must have at least one row." } $normalizedRows = foreach ($row in $Rows) { if (-not $row.ContainsKey('Account') -or [string]::IsNullOrWhiteSpace([string]$row.Account)) { throw "Each invoice row must have an Account." } $quantity = if ($row.ContainsKey('Quantity') -and $null -ne $row.Quantity -and "$($row.Quantity)" -ne '') { [decimal]$row.Quantity } else { $null } $unitPrice = if ($row.ContainsKey('UnitPrice') -and $null -ne $row.UnitPrice -and "$($row.UnitPrice)" -ne '') { [decimal]$row.UnitPrice } else { $null } $computed = if ($null -ne $quantity -and $null -ne $unitPrice) { [decimal]::Parse(([Math]::Round($quantity * $unitPrice, 2)).ToString('0.##', [cultureinfo]::InvariantCulture), [cultureinfo]::InvariantCulture) } else { $null } if ($row.ContainsKey('Amount') -and $null -ne $row.Amount) { $amount = [decimal]$row.Amount if ($null -ne $computed -and $computed -ne $amount) { throw "Invoice row for account $($row.Account): Amount $amount does not equal Quantity $quantity x UnitPrice $unitPrice ($computed)." } } elseif ($null -ne $computed) { $amount = $computed } else { throw "Each invoice row must have an Amount, or a Quantity and a UnitPrice." } if ($amount -le 0) { throw "Invoice row amount for account $($row.Account) must be greater than zero." } $vatRate = if ($row.ContainsKey('VatRate') -and $row.VatRate) { [decimal]$row.VatRate } else { [decimal]0 } $vatAccount = if ($row.ContainsKey('VatAccount')) { [string]$row.VatAccount } else { '' } if ($vatRate -gt 0 -and [string]::IsNullOrWhiteSpace($vatAccount)) { throw "Row for account $($row.Account) has VatRate $vatRate but no VatAccount." } [PSCustomObject]@{ Account = [string]$row.Account Amount = $amount VatRate = $vatRate VatAccount = $vatAccount VatAmount = [Math]::Round($amount * $vatRate, 2) Description = if ($row.ContainsKey('Description')) { [string]$row.Description } else { '' } Quantity = $quantity Unit = if ($row.ContainsKey('Unit')) { [string]$row.Unit } else { '' } UnitPrice = $unitPrice } } $normalizedRows = @($normalizedRows) # Calculate the due date from customer payment terms when not supplied. if (-not $PSBoundParameters.ContainsKey('DueDate')) { $DueDate = $Date.AddDays($customer.PaymentTermsDays) } $invoiceDir = Get-LedgerInvoiceDirectory -JournalPath $JournalPath -Create # Determine the next invoice number by scanning existing files. $existing = Get-ChildItem -Path $invoiceDir -Filter 'inv*.txt' -File -ErrorAction SilentlyContinue if ($existing) { $maxNum = $existing | ForEach-Object { if ($_.BaseName -match '^inv(\d+)$') { [int]$Matches[1] } } | Measure-Object -Maximum | Select-Object -ExpandProperty Maximum $nextNum = $maxNum + 1 } else { $nextNum = 1 } $filePath = Join-Path $invoiceDir (Get-LedgerInvoiceFileName -InvoiceNumber $nextNum) $invoice = [PSCustomObject]@{ InvoiceNumber = $nextNum CustomerNumber = $CustomerNumber InvoiceDate = $Date DueDate = $DueDate Description = $Description Status = 'Draft' ReceivableAccount = $ReceivableAccount BookedVerification = $null BookedFiscalYear = '' Rows = $normalizedRows Payments = @() FilePath = $filePath } if ($PSCmdlet.ShouldProcess("Customer $CustomerNumber", "Create invoice $nextNum")) { Save-LedgerInvoiceFile -Invoice $invoice if ($PassThru) { Get-LedgerInvoice -JournalPath $JournalPath -InvoiceNumber $nextNum } } } |