Private/BankPosting.ps1
|
function Read-LedgerBankPostingFields { [CmdletBinding()] param ([string]$Path) $fields = @{} foreach ($line in Get-Content -LiteralPath $Path -Encoding utf8 -ErrorAction Stop) { if ($line.StartsWith(';') -or -not $line) { continue } if ($line -notmatch '^([A-Za-z]+):\t(.*)$' -or $fields.ContainsKey($Matches[1])) { throw "Invalid bank posting record '$Path': malformed or duplicate field." } $fields[$Matches[1]] = $Matches[2] } $fields } function Save-LedgerBankPostingFields { [CmdletBinding()] param ([string]$Path, [System.Collections.IDictionary]$Fields) $null = New-Item -ItemType Directory -Path (Split-Path $Path -Parent) -Force -ErrorAction Stop $lines = @('; PSLedger bank posting') foreach ($key in $Fields.Keys) { $lines += "${key}:`t$($Fields[$key])" } Set-LedgerFileContent -Path $Path -Value $lines -NoClobber } function Get-LedgerBankPostingOperation { [CmdletBinding()] param ([string]$JournalPath) $directory = Join-Path $JournalPath 'bank' 'posting' if (-not (Test-Path -LiteralPath $directory)) { return } $owners = [Collections.Generic.HashSet[int]]::new() $identities = [Collections.Generic.HashSet[guid]]::new() $reservations = [Collections.Generic.HashSet[string]]::new() foreach ($sidecar in Get-ChildItem -LiteralPath $directory -File -ErrorAction Stop | Where-Object Name -Match '^(counterpart|ambiguous)(\d+)\.txt$') { $null = $sidecar.Name -match '^(counterpart|ambiguous)(\d+)\.txt$' if (-not (Test-Path -LiteralPath (Join-Path $directory "txn$($Matches[2]).txt"))) { throw "Bank posting record '$($sidecar.FullName)' has no corresponding plan." } } foreach ($file in Get-ChildItem -LiteralPath $directory -Filter 'txn*.txt' -File -ErrorAction Stop) { $f = Read-LedgerBankPostingFields -Path $file.FullName foreach ($key in 'FormatVersion', 'OperationId', 'TransactionId', 'StatementNumber', 'BankHash', 'BankAccount', 'Date', 'Amount', 'Account', 'VatRate', 'VatAccount', 'Description', 'MatchType', 'MatchRef', 'FiscalYear', 'VerificationNumber', 'VerificationHash', 'DateTolerance') { if (-not $f.ContainsKey($key)) { throw "Invalid bank posting record '$($file.FullName)': missing $key." } } $id = [guid]::Empty $date = [datetime]::MinValue if ($f.FormatVersion -ne '1' -or -not [guid]::TryParseExact($f.OperationId, 'D', [ref]$id) -or $id -eq [guid]::Empty -or $f.TransactionId -notmatch '^[1-9]\d*$' -or $f.StatementNumber -notmatch '^[1-9]\d*$' -or $f.VerificationNumber -notmatch '^[1-9]\d*$' -or $f.BankAccount -notmatch '^\d+$' -or $f.Account -notmatch '^\d+$' -or $f.Amount -notmatch '^-?\d+(\.\d{1,2})?$' -or $f.VatRate -notmatch '^\d+(\.\d+)?$' -or $f.DateTolerance -notmatch '^\d+$' -or [int]$f.DateTolerance -gt 365 -or $f.MatchType -notin 'Manual', 'Rule' -or ($f.MatchType -eq 'Manual' -and $f.MatchRef -ne $f.Account) -or [string]::IsNullOrWhiteSpace($f.MatchRef) -or $f.BankHash -cnotmatch '^[0-9a-f]{64}$' -or $f.VerificationHash -cnotmatch '^[0-9a-f]{64}$' -or -not [datetime]::TryParseExact($f.Date, 'yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::None, [ref]$date)) { throw "Invalid bank posting record '$($file.FullName)': invalid identity, inputs or hash." } $null = Assert-LedgerSafeLeafName -Name $f.FiscalYear -Kind 'fiscal year' if ($file.Name -ne "txn$($f.TransactionId).txt" -or -not $owners.Add([int]$f.TransactionId) -or -not $identities.Add($id) -or -not $reservations.Add("$($f.FiscalYear)|$($f.VerificationNumber)")) { throw "Bank posting identity or reservation is duplicated or does not match '$($file.Name)'." } $operation = [pscustomobject]@{ Path = $file.FullName; OperationId = $id; TransactionId = [int]$f.TransactionId StatementNumber = [int]$f.StatementNumber; BankHash = $f.BankHash; BankAccount = $f.BankAccount Date = $date; Amount = [decimal]::Parse($f.Amount, [Globalization.CultureInfo]::InvariantCulture) Account = $f.Account; VatRate = [decimal]::Parse($f.VatRate, [Globalization.CultureInfo]::InvariantCulture) VatAccount = $f.VatAccount; Description = $f.Description; MatchType = $f.MatchType; MatchRef = $f.MatchRef FiscalYear = $f.FiscalYear; VerificationNumber = [int]$f.VerificationNumber; VerificationHash = $f.VerificationHash DateTolerance = [int]$f.DateTolerance; Counterpart = $null ManualCounterpart = Test-Path -LiteralPath (Join-Path $directory "ambiguous$($f.TransactionId).txt") } if ($operation.ManualCounterpart) { $marker = Read-LedgerBankPostingFields -Path (Join-Path $directory "ambiguous$($f.TransactionId).txt") if ($marker.OperationId -ne "$id") { throw "Invalid bank posting ambiguity record for transaction $($f.TransactionId)." } } $claimPath = Join-Path $directory "counterpart$($f.TransactionId).txt" if (Test-Path -LiteralPath $claimPath) { $claim = Read-LedgerBankPostingFields -Path $claimPath if ($claim.OperationId -ne "$id" -or $claim.TransactionId -notmatch '^[1-9]\d*$' -or $claim.StatementNumber -notmatch '^[1-9]\d*$' -or $claim.BankHash -cnotmatch '^[0-9a-f]{64}$' -or $operation.VatRate -ne 0 -or -not $owners.Add([int]$claim.TransactionId)) { throw "Invalid or conflicting bank counterpart record '$claimPath'." } $operation.Counterpart = [pscustomobject]@{ TransactionId = [int]$claim.TransactionId; StatementNumber = [int]$claim.StatementNumber; BankHash = $claim.BankHash } } $operation } } function Get-LedgerBankPostingOwner { [CmdletBinding()] param ([object[]]$Operations, [int]$TransactionId) $found = @($Operations | Where-Object { $_.TransactionId -eq $TransactionId -or $_.Counterpart.TransactionId -eq $TransactionId }) if ($found.Count -gt 1) { throw "Bank transaction $TransactionId has conflicting posting plans." } $found } function Assert-LedgerBankPostingSource { [CmdletBinding()] param ([psobject]$Transaction, [psobject]$Operation, [switch]$Counterpart) $source = if ($Counterpart) { $Operation.Counterpart } else { $Operation } $account = if ($Counterpart) { $Operation.Account } else { $Operation.BankAccount } $amount = if ($Counterpart) { -$Operation.Amount } else { $Operation.Amount } $type = if ($Counterpart) { 'Entry' } else { $Operation.MatchType } $ref = if ($Counterpart) { "$($Operation.FiscalYear)/$($Operation.VerificationNumber)" } else { $Operation.MatchRef } if ($Transaction.TransactionId -ne $source.TransactionId -or $Transaction.StatementNumber -ne $source.StatementNumber -or (Get-LedgerBankTransactionHash -Transaction $Transaction) -ne $source.BankHash -or $Transaction.BankAccount -ne $account -or $Transaction.Amount -ne $amount) { throw "Bank transaction $($Transaction.TransactionId) differs from its saved posting plan. Recovery stopped." } if ($Transaction.Status -eq 'Unmatched' -and -not $Transaction.MatchType -and -not $Transaction.MatchRef -and -not $Transaction.FiscalYear -and -not $Transaction.VerificationNumber) { return } if ($Transaction.Status -eq 'Matched' -and $Transaction.MatchType -eq $type -and $Transaction.MatchRef -eq $ref -and $Transaction.FiscalYear -eq $Operation.FiscalYear -and $Transaction.VerificationNumber -eq $Operation.VerificationNumber) { return } throw "Bank transaction $($Transaction.TransactionId) has a conflicting saved posting link." } function Save-LedgerBankPostingCounterpart { [CmdletBinding()] param ([string]$JournalPath, [psobject]$Operation, [psobject]$Transaction) $primary = Find-LedgerBankTransaction -JournalPath $JournalPath -TransactionId $Operation.TransactionId Assert-LedgerBankPostingSource -Transaction $primary.Transaction -Operation $Operation $owners = @(Get-LedgerBankPostingOperation -JournalPath $JournalPath) if ($Operation.Counterpart -or (Get-LedgerBankPostingOwner -Operations $owners -TransactionId $Transaction.TransactionId) -or (Get-LedgerBankInvoicePaymentOperation -Transaction $Transaction -Operations @(Get-LedgerInvoicePaymentOperation -JournalPath $JournalPath)) -or $Operation.VatRate -ne 0 -or $Operation.Account -eq $Operation.BankAccount -or $Transaction.BankAccount -ne $Operation.Account -or $Transaction.Amount -ne -$Operation.Amount -or $Transaction.Status -ne 'Unmatched' -or [Math]::Abs(($Transaction.Date - $Operation.Date).TotalDays) -gt $Operation.DateTolerance) { throw "Bank transaction $($Transaction.TransactionId) cannot be claimed as this posting's counterpart." } $fields = [ordered]@{ OperationId = $Operation.OperationId; TransactionId = $Transaction.TransactionId StatementNumber = $Transaction.StatementNumber; BankHash = Get-LedgerBankTransactionHash -Transaction $Transaction } Save-LedgerBankPostingFields -Path (Join-Path (Split-Path $Operation.Path -Parent) "counterpart$($Operation.TransactionId).txt") -Fields $fields $Operation.Counterpart = [pscustomobject]$fields } function Find-LedgerBankPostingCounterpart { [CmdletBinding()] param ([string]$JournalPath, [psobject]$Operation) if ($Operation.VatRate -ne 0 -or $Operation.Account -eq $Operation.BankAccount -or $Operation.Counterpart -or $Operation.ManualCounterpart) { return } $operations = @(Get-LedgerBankPostingOperation -JournalPath $JournalPath) $payments = @(Get-LedgerInvoicePaymentOperation -JournalPath $JournalPath) $candidates = @(foreach ($s in Get-LedgerBankStatementData -JournalPath $JournalPath) { if ($s.BankAccount -ne $Operation.Account) { continue } foreach ($t in $s.Transactions) { if ($t.Status -ne 'Unmatched' -or $t.Amount -ne -$Operation.Amount -or [Math]::Abs(($t.Date - $Operation.Date).TotalDays) -gt $Operation.DateTolerance) { continue } if (Get-LedgerBankPostingOwner -Operations $operations -TransactionId $t.TransactionId) { continue } if (Get-LedgerBankInvoicePaymentOperation -Transaction $t -Operations $payments) { continue } $t } }) if (-not $candidates.Count) { return } $ordered = @($candidates | Sort-Object { [Math]::Abs(($_.Date - $Operation.Date).TotalDays) }) $nearest = @($ordered | Where-Object { [Math]::Abs(($_.Date - $Operation.Date).TotalDays) -eq [Math]::Abs(($ordered[0].Date - $Operation.Date).TotalDays) }) $ambiguous = $nearest.Count -ne 1 if (-not $ambiguous) { $candidate = $nearest[0] $competing = @($operations | Where-Object { -not $_.Counterpart -and $_.VatRate -eq 0 -and $_.Account -eq $candidate.BankAccount -and $_.Amount -eq -$candidate.Amount -and [Math]::Abs(($_.Date - $candidate.Date).TotalDays) -le $_.DateTolerance } | Sort-Object { [Math]::Abs(($_.Date - $candidate.Date).TotalDays) }) $closest = @($competing | Where-Object { [Math]::Abs(($_.Date - $candidate.Date).TotalDays) -eq [Math]::Abs(($competing[0].Date - $candidate.Date).TotalDays) }) $ambiguous = $closest.Count -gt 1 if (-not $ambiguous -and $closest[0].TransactionId -ne $Operation.TransactionId) { return } } if ($ambiguous) { Save-LedgerBankPostingFields -Path (Join-Path (Split-Path $Operation.Path -Parent) "ambiguous$($Operation.TransactionId).txt") ` -Fields ([ordered]@{ OperationId = $Operation.OperationId }) $Operation.ManualCounterpart = $true Write-Warning "Bank transaction $($Operation.TransactionId) has ambiguous counterparts. Its posting is retained; select the counterpart manually with Set-LedgerBankTransaction -VerificationNumber $($Operation.VerificationNumber) -FiscalYear '$($Operation.FiscalYear)'." return } Save-LedgerBankPostingCounterpart -JournalPath $JournalPath -Operation $Operation -Transaction $nearest[0] } function Complete-LedgerBankPosting { [CmdletBinding()] param ([string]$JournalPath, [psobject]$Operation) try { $primary = Find-LedgerBankTransaction -JournalPath $JournalPath -TransactionId $Operation.TransactionId Assert-LedgerBankPostingSource -Transaction $primary.Transaction -Operation $Operation if ($Operation.Counterpart) { $counter = Find-LedgerBankTransaction -JournalPath $JournalPath -TransactionId $Operation.Counterpart.TransactionId Assert-LedgerBankPostingSource -Transaction $counter.Transaction -Operation $Operation -Counterpart } $rows = @(Get-LedgerBankEntryRows -BankAccount $Operation.BankAccount -Amount $Operation.Amount ` -Account $Operation.Account -VatRate $Operation.VatRate -VatAccount $Operation.VatAccount) $path = Join-Path $JournalPath $Operation.FiscalYear ('ver{0:D4}.txt' -f $Operation.VerificationNumber) if (Test-Path -LiteralPath $path) { Assert-LedgerInvoicePostingVerification -Path $path -Operation $Operation $entry = Get-LedgerEntry -JournalPath $JournalPath -FiscalYear $Operation.FiscalYear -VerificationNumber $Operation.VerificationNumber if ($entry.Date -ne $Operation.Date.ToString('yyyy-MM-dd') -or $entry.Description -ne $Operation.Description -or $entry.Rows.Count -ne $rows.Count) { throw 'Saved bank verification does not match its planned inputs.' } for ($i = 0; $i -lt $rows.Count; $i++) { if ($entry.Rows[$i].Account -ne $rows[$i].Account -or $entry.Rows[$i].Amount -ne $rows[$i].Amount) { throw 'Saved bank verification does not match its planned rows.' } } } else { Assert-LedgerInvoicePostingIntegrity -JournalPath $JournalPath -FiscalYear $Operation.FiscalYear $parameters = @{ JournalPath = $JournalPath; FiscalYear = $Operation.FiscalYear; Date = $Operation.Date Description = $Operation.Description; Rows = $rows; OperationId = $Operation.OperationId } $lines = @(Get-LedgerVerificationLines @parameters) $hash = Get-LedgerSha256Hex -Bytes ([Text.Encoding]::UTF8.GetBytes(($lines -join "`n") + "`n")) if ($hash -ne $Operation.VerificationHash) { throw 'Bank posting inputs no longer produce the saved verification plan.' } Write-LedgerVerification @parameters -ReservedNumber $Operation.VerificationNumber -Confirm:$false } Complete-LedgerInvoicePostingVerification -JournalPath $JournalPath -Operation $Operation Find-LedgerBankPostingCounterpart -JournalPath $JournalPath -Operation $Operation $changed = @() foreach ($id in @($Operation.TransactionId, $Operation.Counterpart.TransactionId)) { if (-not $id) { continue } $found = Find-LedgerBankTransaction -JournalPath $JournalPath -TransactionId $id $counterpart = $id -ne $Operation.TransactionId Assert-LedgerBankPostingSource -Transaction $found.Transaction -Operation $Operation -Counterpart:$counterpart if ($found.Transaction.Status -eq 'Matched') { continue } $type = if ($counterpart) { 'Entry' } else { $Operation.MatchType } $ref = if ($counterpart) { "$($Operation.FiscalYear)/$($Operation.VerificationNumber)" } else { $Operation.MatchRef } Set-LedgerBankTransactionState -Statement $found.Statement -Transaction $found.Transaction -Status Matched ` -MatchType $type -MatchRef $ref -FiscalYear $Operation.FiscalYear -VerificationNumber $Operation.VerificationNumber $changed += ConvertTo-LedgerBankTransactionOutput -Transaction $found.Transaction } [pscustomobject]@{ FiscalYear = $Operation.FiscalYear; VerificationNumber = $Operation.VerificationNumber; Changed = $changed } } catch { throw [InvalidOperationException]::new( "Bank posting for transaction $($Operation.TransactionId) could not complete. Keep '$($Operation.Path)' and retry Invoke-LedgerBankMatching or the original Set-LedgerBankTransaction command. $($_.Exception.Message)", $_.Exception) } } |