Public/Export-LedgerEmployerDeclaration.ps1

<#
.SYNOPSIS
Exports an employer declaration on individual level (arbetsgivardeklaration på
individnivå, AGI) as a Skatteverket XML file.

.DESCRIPTION
Aggregates the posted payslips whose pay date falls in a reporting period (a
calendar month, YYYYMM) and writes an AGI XML file that can be uploaded in
Skatteverket's e-service for employer declarations (Lämna arbetsgivardeklaration
via fil).

The file conforms to Skatteverket's AGI schema. It contains an <Avsandare>
(sender) block, a <Blankettgemensamt> (employer) block, one HU (huvuduppgift)
form with the period totals and one IU (individuppgift) form per employee:

  HU SummaSkatteavdr (faltkod 497) — total withheld preliminary tax
      SummaArbAvgSlf (faltkod 487) — total employer contributions
  IU BetalningsmottagarId (215) — the employee's personal number
      Specifikationsnummer (570) — a per-employee specification number
      KontantErsattningUlagAG (011) — gross cash salary
      AvdrPrelSkatt (001) — withheld preliminary tax

Amounts are reported in whole kronor (truncated), and the HU totals are the sum
of the per-employee whole-krona amounts so the declaration reconciles. Only
payslips with the status Booked are included; an employee must have a personal
number in the employee register (Add-LedgerEmployee -PersonalNumber). The file is
written with UTF-8 encoding.

The sender and contact details default to the journal's OrgNumber and the
ContactName, ContactPhone and ContactEmail metadata fields (set them with
Set-LedgerJournal), and can be overridden with the corresponding parameters.

.PARAMETER JournalPath
The path to an existing journal directory. If omitted, uses the current journal
set via Set-LedgerCurrentJournal.

.PARAMETER Period
The reporting period as YYYYMM (e.g. '202403'). Payslips whose pay date is in this
month are included.

.PARAMETER Path
Destination path for the AGI XML file. Conventionally uses the .xml extension.

.PARAMETER ContactName
The contact person's name. Defaults to the journal's ContactName metadata field.

.PARAMETER ContactPhone
The contact person's phone number. Defaults to the journal's ContactPhone
metadata field.

.PARAMETER ContactEmail
The contact person's e-mail address. Defaults to the journal's ContactEmail
metadata field.

.PARAMETER ProgramName
The program name reported to Skatteverket. Defaults to 'PSLedger'.

.PARAMETER Force
Overwrite the destination file if it already exists.

.EXAMPLE
Export-LedgerEmployerDeclaration -JournalPath .\MinFirma.ledger -Period '202403' -Path .\agi-2024-03.xml

Exports the March 2024 employer declaration for all employees paid that month.

.EXAMPLE
Export-LedgerEmployerDeclaration -Period '202406' -Path C:\AGI\konsult-2024-06.xml -ContactName 'Anna Andersson' -ContactPhone '08-123456' -ContactEmail 'anna@konsult.se' -Force

Exports the June 2024 declaration with explicit contact details, overwriting any
existing file.
#>

function Export-LedgerEmployerDeclaration {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter()]
        [string]$JournalPath,

        [Parameter(Mandatory)]
        [ValidatePattern('^\d{6}$')]
        [string]$Period,

        [Parameter(Mandatory)]
        [string]$Path,

        [Parameter()]
        [string]$ContactName,

        [Parameter()]
        [string]$ContactPhone,

        [Parameter()]
        [string]$ContactEmail,

        [Parameter()]
        [string]$ProgramName = 'PSLedger',

        [switch]$Force
    )
    $JournalPath = Resolve-LedgerJournalPath -JournalPath $JournalPath

    $DestPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
    if ((Test-Path -LiteralPath $DestPath) -and -not $Force) {
        throw "Destination file already exists: $DestPath. Use -Force to overwrite."
    }

    $journal = Get-LedgerJournal -Path $JournalPath
    if (-not $journal.OrgNumber) {
        throw "Journal has no OrgNumber; set one with Set-LedgerJournal before exporting an employer declaration."
    }

    # Normalise an org number or personal number to Skatteverket's 12-digit
    # IDENTITET form: 12 digits are used as-is, a 10-digit org number is prefixed
    # with '16' as Skatteverket requires for Swedish companies.
    $toIdentitet = {
        param($raw, $label)
        $digits = ($raw -replace '\D', '')
        switch ($digits.Length) {
            12 { return $digits }
            10 { return "16$digits" }
            default { throw "Invalid $label '$raw': expected a Swedish org number or personal number (10 or 12 digits)." }
        }
    }

    $orgIdentitet = & $toIdentitet $journal.OrgNumber 'organisationsnummer'

    if (-not $ContactName) { $ContactName = [string]$journal.Metadata['ContactName'] }
    if (-not $ContactPhone) { $ContactPhone = [string]$journal.Metadata['ContactPhone'] }
    if (-not $ContactEmail) { $ContactEmail = [string]$journal.Metadata['ContactEmail'] }

    # Collect the posted payslips for the period and aggregate per employee.
    $payslips = @(Get-LedgerPayslip -JournalPath $JournalPath -Status Booked |
        Where-Object { $_.PayDate -and $_.PayDate.ToString('yyyyMM') -eq $Period })
    if ($payslips.Count -eq 0) {
        throw "No booked payslips found with a pay date in period $Period."
    }

    $employees = @{}
    foreach ($e in @(Get-LedgerEmployee -JournalPath $JournalPath)) {
        $employees[$e.EmployeeNumber] = $e
    }

    $truncate = { param($v) [long][Math]::Truncate([decimal]$v) }

    $individuals = New-Object System.Collections.Generic.List[object]
    foreach ($group in ($payslips | Group-Object EmployeeNumber | Sort-Object Name)) {
        $employee = $employees[$group.Name]
        if (-not $employee) {
            throw "Payslip refers to unknown employee '$($group.Name)'. Add the employee with Add-LedgerEmployee before exporting."
        }
        if (-not $employee.PersonalNumber) {
            throw "Employee '$($group.Name)' ($($employee.Name)) has no personal number. Set one with Set-LedgerEmployee -PersonalNumber before exporting."
        }

        $gross = ($group.Group | Measure-Object -Property GrossSalary -Sum).Sum
        $tax = ($group.Group | Measure-Object -Property TaxAmount -Sum).Sum
        $employer = ($group.Group | Measure-Object -Property EmployerContribution -Sum).Sum

        $individuals.Add([PSCustomObject]@{
            EmployeeNumber       = $group.Name
            PersonalNumber       = & $toIdentitet $employee.PersonalNumber "personnummer for employee '$($group.Name)'"
            GrossSalary          = & $truncate $gross
            TaxWithheld          = & $truncate $tax
            EmployerContribution = & $truncate $employer
            SpecificationNumber  = $individuals.Count + 1
        })
    }

    # HU totals reconcile with the per-employee whole-krona amounts.
    $totalTax = ($individuals | Measure-Object -Property TaxWithheld -Sum).Sum
    $totalEmployer = ($individuals | Measure-Object -Property EmployerContribution -Sum).Sum

    $instansNs = 'http://xmls.skatteverket.se/se/skatteverket/da/instans/schema/1.1'
    $komponentNs = 'http://xmls.skatteverket.se/se/skatteverket/da/komponent/schema/1.1'
    $createdAt = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ss')

    $escapeXml = {
        param($text)
        [System.Security.SecurityElement]::Escape([string]$text)
    }

    $sb = New-Object System.Text.StringBuilder
    $nl = "`r`n"
    $append = {
        param($line)
        [void]$sb.Append($line)
        [void]$sb.Append($nl)
    }

    & $append '<?xml version="1.0" encoding="UTF-8"?>'
    & $append "<Skatteverket omrade=""Arbetsgivardeklaration"" xmlns=""$instansNs"" xmlns:gem=""$komponentNs"">"

    # Sender (Avsandare).
    & $append ' <gem:Avsandare>'
    & $append " <gem:Programnamn>$(& $escapeXml $ProgramName)</gem:Programnamn>"
    & $append " <gem:Organisationsnummer>$orgIdentitet</gem:Organisationsnummer>"
    & $append ' <gem:TekniskKontaktperson>'
    & $append " <gem:Namn>$(& $escapeXml $ContactName)</gem:Namn>"
    & $append " <gem:Telefon>$(& $escapeXml $ContactPhone)</gem:Telefon>"
    & $append " <gem:Epostadress>$(& $escapeXml $ContactEmail)</gem:Epostadress>"
    & $append ' </gem:TekniskKontaktperson>'
    & $append " <gem:Skapad>$createdAt</gem:Skapad>"
    & $append ' </gem:Avsandare>'

    # Employer common data (Blankettgemensamt).
    & $append ' <gem:Blankettgemensamt>'
    & $append ' <gem:Arbetsgivare>'
    & $append " <gem:AgRegistreradId>$orgIdentitet</gem:AgRegistreradId>"
    & $append ' <gem:Kontaktperson>'
    & $append " <gem:Namn>$(& $escapeXml $ContactName)</gem:Namn>"
    & $append " <gem:Telefon>$(& $escapeXml $ContactPhone)</gem:Telefon>"
    & $append " <gem:Epostadress>$(& $escapeXml $ContactEmail)</gem:Epostadress>"
    & $append ' </gem:Kontaktperson>'
    & $append ' </gem:Arbetsgivare>'
    & $append ' </gem:Blankettgemensamt>'

    # Huvuduppgift (HU): the period totals.
    & $append ' <gem:Blankett>'
    & $append ' <gem:Arendeinformation>'
    & $append " <gem:Arendeagare>$orgIdentitet</gem:Arendeagare>"
    & $append " <gem:Period>$Period</gem:Period>"
    & $append ' </gem:Arendeinformation>'
    & $append ' <gem:Blankettinnehall>'
    & $append ' <gem:HU>'
    & $append ' <gem:ArbetsgivareHUGROUP>'
    & $append " <gem:AgRegistreradId faltkod=""201"">$orgIdentitet</gem:AgRegistreradId>"
    & $append ' </gem:ArbetsgivareHUGROUP>'
    & $append " <gem:RedovisningsPeriod faltkod=""006"">$Period</gem:RedovisningsPeriod>"
    & $append " <gem:SummaSkatteavdr faltkod=""497"">$totalTax</gem:SummaSkatteavdr>"
    & $append " <gem:SummaArbAvgSlf faltkod=""487"">$totalEmployer</gem:SummaArbAvgSlf>"
    & $append ' </gem:HU>'
    & $append ' </gem:Blankettinnehall>'
    & $append ' </gem:Blankett>'

    # Individuppgift (IU): one per employee.
    foreach ($iu in $individuals) {
        & $append ' <gem:Blankett>'
        & $append ' <gem:Arendeinformation>'
        & $append " <gem:Arendeagare>$orgIdentitet</gem:Arendeagare>"
        & $append " <gem:Period>$Period</gem:Period>"
        & $append ' </gem:Arendeinformation>'
        & $append ' <gem:Blankettinnehall>'
        & $append ' <gem:IU>'
        & $append ' <gem:ArbetsgivareIUGROUP>'
        & $append " <gem:AgRegistreradId faltkod=""201"">$orgIdentitet</gem:AgRegistreradId>"
        & $append ' </gem:ArbetsgivareIUGROUP>'
        & $append ' <gem:BetalningsmottagareIUGROUP>'
        & $append ' <gem:BetalningsmottagareIDChoice>'
        & $append " <gem:BetalningsmottagarId faltkod=""215"">$($iu.PersonalNumber)</gem:BetalningsmottagarId>"
        & $append ' </gem:BetalningsmottagareIDChoice>'
        & $append ' </gem:BetalningsmottagareIUGROUP>'
        & $append " <gem:RedovisningsPeriod faltkod=""006"">$Period</gem:RedovisningsPeriod>"
        & $append " <gem:Specifikationsnummer faltkod=""570"">$($iu.SpecificationNumber)</gem:Specifikationsnummer>"
        & $append " <gem:KontantErsattningUlagAG faltkod=""011"">$($iu.GrossSalary)</gem:KontantErsattningUlagAG>"
        & $append " <gem:AvdrPrelSkatt faltkod=""001"">$($iu.TaxWithheld)</gem:AvdrPrelSkatt>"
        & $append ' </gem:IU>'
        & $append ' </gem:Blankettinnehall>'
        & $append ' </gem:Blankett>'
    }

    & $append '</Skatteverket>'

    if (-not $PSCmdlet.ShouldProcess($DestPath, "Export employer declaration for $Period")) {
        return
    }

    $encoding = New-Object System.Text.UTF8Encoding($false)
    [System.IO.File]::WriteAllText($DestPath, $sb.ToString(), $encoding)

    [PSCustomObject]@{
        Path                 = $DestPath
        OrgNumber            = $orgIdentitet
        Period               = $Period
        EmployeeCount        = $individuals.Count
        TotalGrossSalary     = ($individuals | Measure-Object -Property GrossSalary -Sum).Sum
        TotalTaxWithheld     = $totalTax
        TotalEmployerContribution = $totalEmployer
    }
}