Private/InvoiceStore.ps1
|
# Helpers for reading and writing invoice files under a journal's 'invoices/' # directory. An invoice is stored as a plain-text file (inv0001.txt) with tab- # separated "Key:\tValue" metadata lines, a 'Rows:' section (one revenue row per # line: Account, NetAmount, VatRate, VatAccount and the optional descriptive # columns Description, Quantity, Unit, UnitPrice) and a 'Payments:' section (one # payment per line: Date, Amount, Verification, FiscalYear). Amounts are written # and read with the invariant culture so the tab-separated numeric columns round # -trip regardless of the machine's regional settings. $script:LedgerInvoiceStatuses = @('Draft', 'Booked', 'Partial', 'Paid', 'Credited') function Format-LedgerInvoiceAmount { <# .SYNOPSIS Formats a decimal for storage using the invariant culture (dot decimal separator) so tab-separated numeric columns are never split by a locale comma. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [decimal]$Value ) return $Value.ToString([System.Globalization.CultureInfo]::InvariantCulture) } function ConvertFrom-LedgerInvoiceAmount { <# .SYNOPSIS Parses a stored amount string back to a decimal using the invariant culture. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Text ) if ([string]::IsNullOrWhiteSpace($Text)) { return [decimal]0 } return [decimal]::Parse($Text, [System.Globalization.CultureInfo]::InvariantCulture) } function Get-LedgerInvoiceDirectory { <# .SYNOPSIS Returns the path to a journal's invoices directory, optionally creating it. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$JournalPath, [switch]$Create ) $dir = Join-Path $JournalPath 'invoices' if ($Create -and -not (Test-Path $dir)) { New-Item -Path $dir -ItemType Directory | Out-Null } return $dir } function Get-LedgerInvoiceFileName { <# .SYNOPSIS Returns the file name (inv0001.txt) for a given invoice number. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [int]$InvoiceNumber ) return 'inv' + $InvoiceNumber.ToString('0000') + '.txt' } function ConvertTo-LedgerInvoiceObject { <# .SYNOPSIS Parses the lines of an invoice file into a rich invoice object with computed NetTotal, VatTotal, Total, PaidAmount and RemainingAmount. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$Content, [Parameter(Mandatory)] [string]$FilePath ) $meta = @{} $rows = @() $payments = @() $charges = @() $section = 'meta' foreach ($line in $Content) { if ($line -match '^\s*;') { continue } if ($line -eq 'Rows:') { $section = 'rows'; continue } if ($line -eq 'Payments:') { $section = 'payments'; continue } if ($line -eq 'Charges:') { $section = 'charges'; continue } switch ($section) { 'rows' { if ([string]::IsNullOrWhiteSpace($line)) { continue } $parts = $line -split "`t" if ($parts.Count -ge 2) { $vatRate = if ($parts.Count -ge 3 -and $parts[2]) { ConvertFrom-LedgerInvoiceAmount -Text $parts[2] } else { [decimal]0 } $net = ConvertFrom-LedgerInvoiceAmount -Text $parts[1] $rows += [PSCustomObject]@{ Account = $parts[0] Amount = $net VatRate = $vatRate VatAccount = if ($parts.Count -ge 4) { $parts[3] } else { '' } VatAmount = [Math]::Round($net * $vatRate, 2) Description = if ($parts.Count -ge 5) { $parts[4] } else { '' } Quantity = if ($parts.Count -ge 6 -and $parts[5]) { ConvertFrom-LedgerInvoiceAmount -Text $parts[5] } else { $null } Unit = if ($parts.Count -ge 7) { $parts[6] } else { '' } UnitPrice = if ($parts.Count -ge 8 -and $parts[7]) { ConvertFrom-LedgerInvoiceAmount -Text $parts[7] } else { $null } } } } 'payments' { if ([string]::IsNullOrWhiteSpace($line)) { continue } $parts = $line -split "`t" if ($parts.Count -ge 2) { $payments += [PSCustomObject]@{ Date = [datetime]::ParseExact($parts[0], 'yyyy-MM-dd', $null) Amount = ConvertFrom-LedgerInvoiceAmount -Text $parts[1] VerificationNumber = if ($parts.Count -ge 3 -and $parts[2]) { [int]$parts[2] } else { $null } FiscalYear = if ($parts.Count -ge 4) { $parts[3] } else { '' } } } } 'charges' { if ([string]::IsNullOrWhiteSpace($line)) { continue } $parts = $line -split "`t" if ($parts.Count -ge 3) { $charges += [PSCustomObject]@{ Date = [datetime]::ParseExact($parts[0], 'yyyy-MM-dd', $null) Type = $parts[1] Amount = ConvertFrom-LedgerInvoiceAmount -Text $parts[2] Account = if ($parts.Count -ge 4) { $parts[3] } else { '' } VerificationNumber = if ($parts.Count -ge 5 -and $parts[4]) { [int]$parts[4] } else { $null } FiscalYear = if ($parts.Count -ge 6) { $parts[5] } else { '' } } } } default { $parts = $line -split "`t", 2 if ($parts.Count -ge 2) { $meta[$parts[0].TrimEnd(':')] = $parts[1] } } } } $netTotal = ($rows | Measure-Object -Property Amount -Sum).Sum if (-not $netTotal) { $netTotal = [decimal]0 } $vatTotal = [decimal]0 foreach ($r in $rows) { $vatTotal += $r.VatAmount } $chargesTotal = ($charges | Measure-Object -Property Amount -Sum).Sum if (-not $chargesTotal) { $chargesTotal = [decimal]0 } $total = [Math]::Round($netTotal + $vatTotal + $chargesTotal, 2) $paidAmount = ($payments | Measure-Object -Property Amount -Sum).Sum if (-not $paidAmount) { $paidAmount = [decimal]0 } [PSCustomObject]@{ InvoiceNumber = [int]$meta['InvoiceNumber'] CustomerNumber = $meta['CustomerNumber'] InvoiceDate = if ($meta['InvoiceDate']) { [datetime]::ParseExact($meta['InvoiceDate'], 'yyyy-MM-dd', $null) } else { $null } DueDate = if ($meta['DueDate']) { [datetime]::ParseExact($meta['DueDate'], 'yyyy-MM-dd', $null) } else { $null } Description = $meta['Description'] Status = $meta['Status'] ReceivableAccount = $meta['ReceivableAccount'] BookedVerification = if ($meta['BookedVerification']) { [int]$meta['BookedVerification'] } else { $null } BookedFiscalYear = $meta['BookedFiscalYear'] OcrReference = if ($meta['InvoiceNumber']) { Get-LedgerOcrReference -BaseNumber ([string]$meta['InvoiceNumber']) } else { '' } ReminderCount = if ($meta['ReminderCount']) { [int]$meta['ReminderCount'] } else { 0 } LastReminderDate = if ($meta['LastReminderDate']) { [datetime]::ParseExact($meta['LastReminderDate'], 'yyyy-MM-dd', $null) } else { $null } Rows = $rows Payments = $payments Charges = $charges NetTotal = [Math]::Round([decimal]$netTotal, 2) VatTotal = [Math]::Round($vatTotal, 2) ChargesTotal = [Math]::Round([decimal]$chargesTotal, 2) Total = $total PaidAmount = [Math]::Round([decimal]$paidAmount, 2) RemainingAmount = [Math]::Round($total - $paidAmount, 2) FilePath = $FilePath } } function Read-LedgerInvoiceFile { <# .SYNOPSIS Reads and parses a single invoice file, returning an invoice object. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$Path ) if (-not (Test-Path $Path -PathType Leaf)) { throw "Invoice file not found: $Path" } $content = @(Get-Content -Path $Path -Encoding UTF8) return ConvertTo-LedgerInvoiceObject -Content $content -FilePath $Path } function Save-LedgerInvoiceFile { <# .SYNOPSIS Serialises an invoice object back to its file. .DESCRIPTION Writes the metadata, 'Rows:' and 'Payments:' sections. Accepts either an object produced by ConvertTo-LedgerInvoiceObject or a PSCustomObject with the same properties. The FilePath property determines the destination. #> [CmdletBinding()] param ( [Parameter(Mandatory)] [psobject]$Invoice ) $lines = @( '; PSLedger Invoice' "InvoiceNumber:`t$($Invoice.InvoiceNumber)" "CustomerNumber:`t$($Invoice.CustomerNumber)" "InvoiceDate:`t$($Invoice.InvoiceDate.ToString('yyyy-MM-dd'))" "DueDate:`t$($Invoice.DueDate.ToString('yyyy-MM-dd'))" "Description:`t$(ConvertTo-LedgerTextField $Invoice.Description)" "Status:`t$($Invoice.Status)" "ReceivableAccount:`t$($Invoice.ReceivableAccount)" "BookedVerification:`t$(if ($null -ne $Invoice.BookedVerification) { $Invoice.BookedVerification } else { '' })" "BookedFiscalYear:`t$($Invoice.BookedFiscalYear)" "ReminderCount:`t$(if ($Invoice.PSObject.Properties['ReminderCount'] -and $Invoice.ReminderCount) { $Invoice.ReminderCount } else { 0 })" "LastReminderDate:`t$(if ($Invoice.PSObject.Properties['LastReminderDate'] -and $Invoice.LastReminderDate) { $Invoice.LastReminderDate.ToString('yyyy-MM-dd') } else { '' })" 'Rows:' ) foreach ($row in $Invoice.Rows) { $line = "$($row.Account)`t$(Format-LedgerInvoiceAmount -Value ([decimal]$row.Amount))`t$(Format-LedgerInvoiceAmount -Value ([decimal]$row.VatRate))`t$($row.VatAccount)" # The descriptive columns are optional and only written when used, so # invoices without them keep the original four-column layout. $get = { param($name) if ($row.PSObject.Properties[$name]) { $row.$name } else { $null } } $desc = [string](& $get 'Description') -replace '[\t\r\n]+', ' ' $qty = & $get 'Quantity' $unit = [string](& $get 'Unit') -replace '[\t\r\n]+', ' ' $price = & $get 'UnitPrice' if ($desc -or $null -ne $qty -or $unit -or $null -ne $price) { $qtyText = if ($null -ne $qty) { Format-LedgerInvoiceAmount -Value ([decimal]$qty) } else { '' } $priceText = if ($null -ne $price) { Format-LedgerInvoiceAmount -Value ([decimal]$price) } else { '' } $line += "`t$desc`t$qtyText`t$unit`t$priceText" } $lines += $line } $lines += 'Payments:' foreach ($p in $Invoice.Payments) { $veriField = if ($null -ne $p.VerificationNumber) { $p.VerificationNumber } else { '' } $lines += "$($p.Date.ToString('yyyy-MM-dd'))`t$(Format-LedgerInvoiceAmount -Value ([decimal]$p.Amount))`t$veriField`t$($p.FiscalYear)" } $lines += 'Charges:' if ($Invoice.PSObject.Properties['Charges']) { foreach ($c in $Invoice.Charges) { $veriField = if ($null -ne $c.VerificationNumber) { $c.VerificationNumber } else { '' } $lines += "$($c.Date.ToString('yyyy-MM-dd'))`t$($c.Type)`t$(Format-LedgerInvoiceAmount -Value ([decimal]$c.Amount))`t$($c.Account)`t$veriField`t$($c.FiscalYear)" } } Set-LedgerFileContent -Path $Invoice.FilePath -Value $lines } |