Private/VatBasMapping.ps1

# BAS account to Swedish VAT declaration box mapping.
#
# Maps BAS accounts to the boxes (rutor) of Skatteverket's VAT return
# (skattedeklaration för moms) and the boxes to the eSKD 6.0 XML tags. A journal
# can override the default mapping with a vat.txt file (see docs/File-format.md).

function Get-VatBoxDefinition {
    <#
    .SYNOPSIS
    Returns the VAT declaration boxes in Skatteverket's eSKD order.

    .DESCRIPTION
    Kind decides how account totals become a box amount: Sales and OutputVat
    boxes hold credit balances (reported negated), PurchaseBase and InputVat
    boxes hold debit balances. Box 49 is derived from the others.
    #>

    [CmdletBinding()]
    param()

    $boxes = @(
        ,@(5, 'ForsMomsEjAnnan', 'Sales', 'Momspliktig försäljning')
        ,@(6, 'UttagMoms', 'Sales', 'Momspliktiga uttag')
        ,@(7, 'UlagMargbesk', 'Sales', 'Beskattningsunderlag vid vinstmarginalbeskattning')
        ,@(8, 'HyrinkomstFriv', 'Sales', 'Hyresinkomster vid frivillig skattskyldighet')
        ,@(20, 'InkopVaruAnnatEg', 'PurchaseBase', 'Inköp av varor från ett annat EU-land')
        ,@(21, 'InkopTjanstAnnatEg', 'PurchaseBase', 'Inköp av tjänster från ett annat EU-land')
        ,@(22, 'InkopTjanstUtomEg', 'PurchaseBase', 'Inköp av tjänster från ett land utanför EU')
        ,@(23, 'InkopVaruSverige', 'PurchaseBase', 'Inköp av varor i Sverige (omvänd skattskyldighet)')
        ,@(24, 'InkopTjanstSverige', 'PurchaseBase', 'Övriga inköp av tjänster (omvänd skattskyldighet)')
        ,@(50, 'MomsUlagImport', 'PurchaseBase', 'Beskattningsunderlag vid import')
        ,@(35, 'ForsVaruAnnatEg', 'Sales', 'Försäljning av varor till ett annat EU-land')
        ,@(36, 'ForsVaruUtomEg', 'Sales', 'Försäljning av varor utanför EU')
        ,@(37, 'InkopVaruMellan3p', 'PurchaseBase', 'Mellanmans inköp av varor vid trepartshandel')
        ,@(38, 'ForsVaruMellan3p', 'Sales', 'Mellanmans försäljning av varor vid trepartshandel')
        ,@(39, 'ForsTjSkskAnnatEg', 'Sales', 'Försäljning av tjänster till ett annat EU-land')
        ,@(40, 'ForsTjOvrUtomEg', 'Sales', 'Övrig försäljning av tjänster omsatta utomlands')
        ,@(41, 'ForsKopareSkskSverige', 'Sales', 'Försäljning när köparen är skattskyldig i Sverige')
        ,@(42, 'ForsOvrigt', 'Sales', 'Övrig försäljning m.m.')
        ,@(10, 'MomsUtgHog', 'OutputVat', 'Utgående moms 25 %')
        ,@(11, 'MomsUtgMedel', 'OutputVat', 'Utgående moms 12 %')
        ,@(12, 'MomsUtgLag', 'OutputVat', 'Utgående moms 6 %')
        ,@(30, 'MomsInkopUtgHog', 'OutputVat', 'Utgående moms 25 % på inköp')
        ,@(31, 'MomsInkopUtgMedel', 'OutputVat', 'Utgående moms 12 % på inköp')
        ,@(32, 'MomsInkopUtgLag', 'OutputVat', 'Utgående moms 6 % på inköp')
        ,@(60, 'MomsImportUtgHog', 'OutputVat', 'Utgående moms 25 % vid import')
        ,@(61, 'MomsImportUtgMedel', 'OutputVat', 'Utgående moms 12 % vid import')
        ,@(62, 'MomsImportUtgLag', 'OutputVat', 'Utgående moms 6 % vid import')
        ,@(48, 'MomsIngAvdr', 'InputVat', 'Ingående moms att dra av')
        ,@(49, 'MomsBetala', 'Result', 'Moms att betala eller få tillbaka')
    )
    foreach ($b in $boxes) {
        [PSCustomObject]@{ Box = $b[0]; Tag = $b[1]; Kind = $b[2]; Name = $b[3] }
    }
}

function Get-VatDefaultRule {
    <#
    .SYNOPSIS
    Returns the default BAS account ranges per box, most specific first.

    .DESCRIPTION
    The last rule, 3000-3799 to box 05, is the catch-all for taxable sales and
    is marked Implicit: accounts that only match it are reported in box 05.
    #>

    [CmdletBinding()]
    param()

    $rules = @(
        ,@(3004, 3004, 42)   # Försäljning inom Sverige, momsfri
        ,@(3105, 3105, 36)   # Försäljning varor till land utanför EU
        ,@(3107, 3107, 38)   # Försäljning varor, mellanman vid trepartshandel
        ,@(3108, 3108, 35)   # Försäljning varor till annat EU-land, momsfri
        ,@(3211, 3211, 7)    # Separately booked taxable margin, excluding VAT
        ,@(3212, 3212, $null) # Negative VMB has no taxable base
        ,@(3231, 3233, 41)   # Försäljning inom byggsektorn m.m., omvänd skattskyldighet
        ,@(3305, 3305, 40)   # Försäljning tjänster till land utanför EU
        ,@(3308, 3308, 39)   # Försäljning tjänster till annat EU-land
        ,@(3401, 3403, 6)    # Egna uttag, momspliktiga
        ,@(3404, 3404, 42)   # Egna uttag, momsfria
        ,@(3913, 3913, 8)    # Frivilligt momspliktiga hyresintäkter
        ,@(3914, 3914, 5)    # Övriga momspliktiga hyresintäkter
        ,@(4415, 4417, 23)   # Inköpta varor i Sverige, omvänd skattskyldighet
        ,@(4425, 4427, 24)   # Inköpta tjänster i Sverige, omvänd skattskyldighet
        ,@(4512, 4512, 37)   # Inköp av varor, mellanman vid trepartshandel
        ,@(4515, 4517, 20)   # Inköp av varor från annat EU-land
        ,@(4531, 4533, 22)   # Inköp av tjänster från land utanför EU
        ,@(4535, 4537, 21)   # Inköp av tjänster från annat EU-land
        ,@(4545, 4547, 50)   # Import av varor, beskattningsunderlag
        ,@(2610, 2613, 10)   # Utgående moms 25 %
        ,@(2616, 2616, 10)   # Utgående moms VMB 25 %
        ,@(2614, 2614, 30)   # Utgående moms omvänd skattskyldighet 25 %
        ,@(2617, 2617, 30)
        ,@(2615, 2615, 60)   # Utgående moms import av varor 25 %
        ,@(2620, 2623, 11)
        ,@(2626, 2626, 11)
        ,@(2624, 2624, 31)
        ,@(2627, 2627, 31)
        ,@(2625, 2625, 61)
        ,@(2630, 2633, 12)
        ,@(2636, 2636, 12)
        ,@(2634, 2634, 32)
        ,@(2637, 2637, 32)
        ,@(2635, 2635, 62)
        ,@(2640, 2647, 48)   # Ingående moms (2648 vilande moms is excluded)
        ,@(2649, 2649, 48)
    )
    foreach ($r in $rules) {
        [PSCustomObject]@{
            From = [long]$r[0]; To = [long]$r[1]
            Box = if ($null -eq $r[2]) { $null } else { [int]$r[2] }
            Source = 'Default'; Implicit = $false
        }
    }
    [PSCustomObject]@{ From = 3000L; To = 3799L; Box = 5; Source = 'Default'; Implicit = $true }
}

function Get-LedgerVatMappingPath {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$JournalPath
    )
    Join-Path $JournalPath 'vat.txt'
}

function Read-LedgerVatMapping {
    <#
    .SYNOPSIS
    Reads the journal's vat.txt override rules in file order.

    .DESCRIPTION
    Each record is an account or an account range (3000-3099) and a box number,
    or '-' for no box. Throws on a record it cannot read, since a silently
    ignored rule would put amounts in the wrong box of a tax return.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$JournalPath
    )
    $path = Get-LedgerVatMappingPath -JournalPath $JournalPath
    if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return }

    $valid = @(Get-VatBoxDefinition | Where-Object Kind -ne 'Result' | ForEach-Object Box)
    $lineNumber = 0
    foreach ($line in (Get-Content -LiteralPath $path -Encoding UTF8)) {
        $lineNumber++
        if ([string]::IsNullOrWhiteSpace($line) -or $line -match '^\s*;') { continue }
        $p = $line -split "`t"
        if ($p.Count -lt 2 -or $p[0].Trim() -notmatch '^(\d+)(?:-(\d+))?$') {
            throw "vat.txt line ${lineNumber}: expected '<account or range><TAB><box>', got '$line'."
        }
        $from = [long]$Matches[1]
        $to = if ($Matches[2]) { [long]$Matches[2] } else { $from }
        if ($to -lt $from) {
            throw "vat.txt line ${lineNumber}: range '$($p[0].Trim())' ends before it starts."
        }
        $boxText = $p[1].Trim()
        $box = $null
        if ($boxText -ne '-') {
            if ($boxText -notmatch '^\d{1,2}$' -or [int]$boxText -notin $valid) {
                throw "vat.txt line ${lineNumber}: '$boxText' is not a VAT declaration box. Use a box number such as 05 or 39, or '-' for no box."
            }
            $box = [int]$boxText
        }
        [PSCustomObject]@{ From = $from; To = $to; Box = $box; Source = 'vat.txt'; Implicit = $false }
    }
}

function Save-LedgerVatMapping {
    <#
    .SYNOPSIS
    Writes vat.txt override rules in the given order.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$JournalPath,

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]]$Rules
    )
    $lines = @('; PSLedger VAT mapping: Account or range, Box')
    foreach ($r in $Rules) {
        $account = if ($r.From -eq $r.To) { "$($r.From)" } else { "$($r.From)-$($r.To)" }
        $box = if ($null -eq $r.Box) { '-' } else { '{0:D2}' -f [int]$r.Box }
        $lines += "$account`t$box"
    }
    Set-LedgerFileContent -Path (Get-LedgerVatMappingPath -JournalPath $JournalPath) -Value $lines
}

function Get-VatBoxMapping {
    <#
    .SYNOPSIS
    Returns the effective mapping rules: vat.txt rules first, then the defaults.
    #>

    [CmdletBinding()]
    param (
        [Parameter()]
        [string]$JournalPath
    )
    if ($JournalPath) { Read-LedgerVatMapping -JournalPath $JournalPath }
    Get-VatDefaultRule
}

function Resolve-VatBoxRule {
    <#
    .SYNOPSIS
    Returns the first rule that matches an account, or $null.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$Account,

        [Parameter(Mandatory)]
        [object[]]$Rules
    )
    if ($Account -notmatch '^\d+$') { return $null }
    $number = [long]$Account
    foreach ($rule in $Rules) {
        if ($number -ge $rule.From -and $number -le $rule.To) { return $rule }
    }
    $null
}

function Get-VatEskdTagMap {
    <#
    .SYNOPSIS
    Returns the eSKD 6.0 XML tag of each box in Skatteverket's order.
    #>

    [CmdletBinding()]
    param()

    Get-VatBoxDefinition | Select-Object Box, Tag
}