Public/New-LedgerPayslip.ps1

<#
.SYNOPSIS
Creates a new payslip (lönespecifikation) in the journal.

.DESCRIPTION
Creates a sequentially numbered payslip file (pay0001.txt, pay0002.txt, etc.) in
the journal's 'payslips/' directory. A payslip records an employee's gross salary,
the preliminary tax withheld and the employer's social security contributions for
a pay period, but is not yet posted to the ledger — use Invoke-LedgerPayrollPosting
to create the bookkeeping verification.

The preliminary tax is resolved in this order: an explicit -TaxAmount, otherwise
-TaxRate times the gross salary, otherwise the employee's default tax rate. The
net pay is the gross salary minus the tax. The employer contribution is the gross
salary times -EmployerContributionRate (default 0.3142, the standard Swedish
arbetsgivaravgift).

Payslips live at the journal level (not inside a fiscal year) because a pay period
may be posted in a later fiscal year than the one it was created in.

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

.PARAMETER EmployeeNumber
The number of the employee being paid. Must exist in the employee register
(see Add-LedgerEmployee).

.PARAMETER GrossSalary
The gross (pre-tax) cash salary for the period. Must be greater than zero.

.PARAMETER PayDate
The pay (disbursement) date. Defaults to today.

.PARAMETER PeriodStart
Optional. The first day of the pay period.

.PARAMETER PeriodEnd
Optional. The last day of the pay period.

.PARAMETER Description
A description of the payslip. Defaults to 'Lön'.

.PARAMETER TaxAmount
The preliminary tax to withhold, as an amount. Mutually exclusive with -TaxRate.

.PARAMETER TaxRate
The preliminary tax to withhold, as a decimal rate of the gross salary (e.g. 0.30
for 30%). Mutually exclusive with -TaxAmount. If neither is given, the employee's
default tax rate is used.

.PARAMETER EmployerContributionRate
The employer social security contribution rate as a decimal. Defaults to 0.3142.

.PARAMETER SalaryAccount
The salary cost account the gross pay is debited to. Defaults to the employee's
SalaryAccount (7210 unless changed).

.PARAMETER TaxLiabilityAccount
The account the withheld tax is credited to. Defaults to '2710' (Personalskatt).

.PARAMETER NetPayAccount
The account the net pay is credited to (disbursement). Defaults to '1930'
(Företagskonto).

.PARAMETER EmployerContributionAccount
The cost account the employer contribution is debited to. Defaults to '7510'
(Arbetsgivaravgifter).

.PARAMETER EmployerContributionLiabilityAccount
The account the employer contribution is credited to. Defaults to '2730'
(Arbetsgivaravgift skuld).

.PARAMETER PassThru
If specified, returns the created payslip object. By default the command produces
no output.

.EXAMPLE
New-LedgerPayslip -JournalPath .\MinFirma.ledger -EmployeeNumber '1' -GrossSalary 30000 -TaxRate 0.30 -PayDate '2024-03-25'

Creates a payslip for 30 000 kr gross with 30% preliminary tax.

.EXAMPLE
New-LedgerPayslip -EmployeeNumber '2' -GrossSalary 42000 -TaxAmount 12600 -PeriodStart '2024-03-01' -PeriodEnd '2024-03-31' -Description 'Lön mars 2024' -PassThru

Creates a payslip with an explicit tax amount and pay period, and returns it.
#>

function New-LedgerPayslip {
    [OutputType([pscustomobject])]
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter()]
        [string]$JournalPath,

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

        [Parameter(Mandatory)]
        [decimal]$GrossSalary,

        [Parameter()]
        [datetime]$PayDate = (Get-Date).Date,

        [Parameter()]
        [datetime]$PeriodStart,

        [Parameter()]
        [datetime]$PeriodEnd,

        [Parameter()]
        [string]$Description = 'Lön',

        [Parameter()]
        [decimal]$TaxAmount,

        [Parameter()]
        [ValidateRange(0, 1)]
        [decimal]$TaxRate,

        [Parameter()]
        [ValidateRange(0, 1)]
        [decimal]$EmployerContributionRate = 0.3142,

        [Parameter()]
        [string]$SalaryAccount,

        [Parameter()]
        [string]$TaxLiabilityAccount = '2710',

        [Parameter()]
        [string]$NetPayAccount = '1930',

        [Parameter()]
        [string]$EmployerContributionAccount = '7510',

        [Parameter()]
        [string]$EmployerContributionLiabilityAccount = '2730',

        [Parameter()]
        [switch]$PassThru
    )
    $JournalPath = Resolve-LedgerJournalPath -JournalPath $JournalPath -SchemaCheck Write

    if (-not (Test-Path $JournalPath -PathType Container)) {
        throw "Journal not found: $JournalPath"
    }

    # Validate the employee exists.
    $employee = Get-LedgerEmployee -JournalPath $JournalPath -EmployeeNumber $EmployeeNumber
    if (-not $employee) {
        throw "Employee '$EmployeeNumber' does not exist. Add it with Add-LedgerEmployee first."
    }

    if ($GrossSalary -le 0) {
        throw "GrossSalary must be greater than zero. Got: $GrossSalary"
    }

    # Resolve the preliminary tax.
    if ($PSBoundParameters.ContainsKey('TaxAmount') -and $PSBoundParameters.ContainsKey('TaxRate')) {
        throw "Specify either -TaxAmount or -TaxRate, not both."
    }
    if ($PSBoundParameters.ContainsKey('TaxAmount')) {
        $tax = [Math]::Round([decimal]$TaxAmount, 2)
    }
    elseif ($PSBoundParameters.ContainsKey('TaxRate')) {
        $tax = [Math]::Round($GrossSalary * $TaxRate, 2)
    }
    else {
        $tax = [Math]::Round($GrossSalary * $employee.TaxRate, 2)
    }

    if ($tax -lt 0) {
        throw "Tax amount must not be negative. Got: $tax"
    }
    if ($tax -gt $GrossSalary) {
        throw "Tax amount ($tax) must not exceed the gross salary ($GrossSalary)."
    }

    # Default the salary account to the employee's account.
    if (-not $PSBoundParameters.ContainsKey('SalaryAccount')) {
        $SalaryAccount = $employee.SalaryAccount
    }

    $payslipDir = Get-LedgerPayslipDirectory -JournalPath $JournalPath -Create

    # Determine the next payslip number by scanning existing files.
    $existing = Get-ChildItem -Path $payslipDir -Filter 'pay*.txt' -File -ErrorAction SilentlyContinue
    if ($existing) {
        $maxNum = $existing |
            ForEach-Object { if ($_.BaseName -match '^pay(\d+)$') { [int]$Matches[1] } } |
            Measure-Object -Maximum |
            Select-Object -ExpandProperty Maximum
        $nextNum = $maxNum + 1
    }
    else {
        $nextNum = 1
    }

    $filePath = Join-Path $payslipDir (Get-LedgerPayslipFileName -PayslipNumber $nextNum)

    $payslip = [PSCustomObject]@{
        PayslipNumber                        = $nextNum
        EmployeeNumber                       = $EmployeeNumber
        PayDate                              = $PayDate
        PeriodStart                          = if ($PSBoundParameters.ContainsKey('PeriodStart')) { $PeriodStart } else { $null }
        PeriodEnd                            = if ($PSBoundParameters.ContainsKey('PeriodEnd')) { $PeriodEnd } else { $null }
        Description                          = $Description
        Status                               = 'Draft'
        GrossSalary                          = [Math]::Round($GrossSalary, 2)
        TaxAmount                            = $tax
        EmployerContributionRate             = $EmployerContributionRate
        SalaryAccount                        = $SalaryAccount
        TaxLiabilityAccount                  = $TaxLiabilityAccount
        NetPayAccount                        = $NetPayAccount
        EmployerContributionAccount          = $EmployerContributionAccount
        EmployerContributionLiabilityAccount = $EmployerContributionLiabilityAccount
        BookedVerification                   = $null
        BookedFiscalYear                     = ''
        FilePath                             = $filePath
    }

    if ($PSCmdlet.ShouldProcess("Employee $EmployeeNumber", "Create payslip $nextNum")) {
        Save-LedgerPayslipFile -Payslip $payslip

        if ($PassThru) {
            Get-LedgerPayslip -JournalPath $JournalPath -PayslipNumber $nextNum
        }
    }
}