Public/New-LedgerTimeInvoice.ps1

<#
.SYNOPSIS
Invoices a customer's reported time.

.DESCRIPTION
Collects the customer's open, billable time up to and including -Through and
creates a customer invoice (draft) with New-LedgerInvoice. By default there is
one invoice row per project, resource and hourly rate, e.g.
"Ny kundportal – Anna Andersson, mars 2024" with the hours as quantity, 'h' as
unit and the hourly rate as unit price. With -PerEntry there is one row per
time entry instead, with its date and text.

The time entries are linked to the invoice and become 'Invoiced'; they can no
longer be changed or removed. If the invoice is credited with
Add-LedgerCreditInvoice the entries become 'Open' again and can be invoiced
anew.

Post the invoice with Invoke-LedgerInvoicePosting and export it with
Export-LedgerInvoice as usual.

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

.PARAMETER CustomerNumber
The customer to invoice.

.PARAMETER Through
The last date of time to include. Defaults to today.

.PARAMETER FromDate
Optional. The first date of time to include.

.PARAMETER ProjectNumber
Optional. Only invoice time on these projects.

.PARAMETER Date
The invoice date. Defaults to today.

.PARAMETER DueDate
Optional due date; defaults to the invoice date plus the customer's payment
terms.

.PARAMETER Description
The invoice description. Defaults to 'Konsulttjänster <period>'.

.PARAMETER Account
The revenue account. Defaults to '3010'.

.PARAMETER VatRate
The VAT rate. Defaults to 0.25.

.PARAMETER VatAccount
The output VAT account. Defaults to '2610'.

.PARAMETER PerEntry
One invoice row per time entry instead of per project and resource.

.PARAMETER PassThru
Returns the created invoice.

.EXAMPLE
New-LedgerTimeInvoice -CustomerNumber 10 -Through 2024-03-31 -Date 2024-03-31

Invoices Volvo AB's open time up to the end of March.

.EXAMPLE
$inv = New-LedgerTimeInvoice -CustomerNumber 10 -ProjectNumber 1001 -Through 2024-03-31 -PerEntry -PassThru
Invoke-LedgerInvoicePosting -InvoiceNumber $inv.InvoiceNumber
Export-LedgerInvoice -InvoiceNumber $inv.InvoiceNumber -Path ".\faktura-$($inv.InvoiceNumber).pdf"

Invoices one project with a row per entry, posts the invoice and exports it.
#>

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

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

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

        [Parameter()]
        [datetime]$FromDate,

        [Parameter()]
        [string[]]$ProjectNumber,

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

        [Parameter()]
        [datetime]$DueDate,

        [Parameter()]
        [string]$Description,

        [Parameter()]
        [string]$Account = '3010',

        [Parameter()]
        [ValidateRange(0, 1)]
        [decimal]$VatRate = 0.25,

        [Parameter()]
        [string]$VatAccount = '2610',

        [Parameter()]
        [switch]$PerEntry,

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

    if (-not (Get-LedgerCustomer -JournalPath $JournalPath -CustomerNumber $CustomerNumber)) {
        throw "Customer '$CustomerNumber' does not exist."
    }

    $entries = @(Read-LedgerTimeEntries -JournalPath $JournalPath)
    $context = Get-LedgerTimeContext -JournalPath $JournalPath
    $invoiceStatus = Get-LedgerTimeInvoiceStatusMap -JournalPath $JournalPath

    $selected = @($entries | Where-Object {
            $_.CustomerNumber -eq $CustomerNumber -and $_.Billable -and $_.Date -le $Through.Date -and
            (-not $PSBoundParameters.ContainsKey('FromDate') -or $_.Date -ge $FromDate.Date) -and
            (-not $ProjectNumber -or $_.ProjectNumber -in $ProjectNumber) -and
            -not (Test-LedgerTimeEntryInvoiced -Entry $_ -InvoiceStatus $invoiceStatus)
        })
    if (-not $selected) {
        Write-Warning "Customer $CustomerNumber has no open billable time up to $($Through.ToString('yyyy-MM-dd'))."
        return
    }
    $missingRate = @($selected | Where-Object { $null -eq $_.Rate })
    if ($missingRate) {
        throw "Time entries without an hourly rate: $(($missingRate.EntryId) -join ', '). Set one with Set-LedgerTimeEntry -Rate."
    }

    $sv = [System.Globalization.CultureInfo]::GetCultureInfo('sv-SE')
    $periodText = {
        param($items)
        $min = ($items | Measure-Object -Property Date -Minimum).Minimum
        $max = ($items | Measure-Object -Property Date -Maximum).Maximum
        if ($min.ToString('yyyy-MM') -eq $max.ToString('yyyy-MM')) { $min.ToString('MMMM yyyy', $sv) }
        else { "$($min.ToString('yyyy-MM-dd')) – $($max.ToString('yyyy-MM-dd'))" }
    }
    $nameOf = {
        param($list, $key, $value)
        $item = $list | Where-Object $key -eq $value | Select-Object -First 1
        if ($item -and $item.Name) { $item.Name } else { $value }
    }

    $baseRow = @{ Account = $Account; Unit = 'h' }
    if ($VatRate -gt 0) { $baseRow.VatRate = $VatRate; $baseRow.VatAccount = $VatAccount }

    $rows = [System.Collections.Generic.List[hashtable]]::new()
    if ($PerEntry) {
        foreach ($e in ($selected | Sort-Object Date, EntryId)) {
            $who = & $nameOf $context.Resources 'ResourceId' $e.ResourceId
            $label = if ($e.ProjectNumber) { "$(& $nameOf $context.Projects 'ProjectNumber' $e.ProjectNumber), $who" } else { $who }
            $text = "$($e.Date.ToString('yyyy-MM-dd')) $label"
            if ($e.Text) { $text += ": $($e.Text)" }
            $row = $baseRow.Clone()
            $row.Description = $text; $row.Quantity = $e.Hours; $row.UnitPrice = $e.Rate
            $rows.Add($row)
        }
    }
    else {
        $groups = $selected | Group-Object ProjectNumber, ResourceId, { Format-LedgerInvoiceAmount -Value ([decimal]$_.Rate) }
        $ordered = $groups | Sort-Object { $_.Group[0].ProjectNumber }, { & $nameOf $context.Resources 'ResourceId' $_.Group[0].ResourceId }, { [decimal]$_.Group[0].Rate }
        foreach ($g in $ordered) {
            $first = $g.Group[0]
            $who = & $nameOf $context.Resources 'ResourceId' $first.ResourceId
            $label = if ($first.ProjectNumber) { "$(& $nameOf $context.Projects 'ProjectNumber' $first.ProjectNumber) – $who" } else { $who }
            $hours = [decimal]0
            foreach ($e in $g.Group) { $hours += [decimal]$e.Hours }
            $row = $baseRow.Clone()
            $row.Description = "$label, $(& $periodText $g.Group)"
            $row.Quantity = $hours; $row.UnitPrice = [decimal]$first.Rate
            $rows.Add($row)
        }
    }

    if (-not $Description) { $Description = "Konsulttjänster $(& $periodText $selected)" }
    $totalHours = ($selected | Measure-Object -Property Hours -Sum).Sum
    if (-not $PSCmdlet.ShouldProcess("Customer $CustomerNumber", "Invoice $totalHours h in $($selected.Count) time entries")) { return }

    $invoiceParams = @{
        JournalPath    = $JournalPath
        CustomerNumber = $CustomerNumber
        Date           = $Date
        Description    = $Description
        Rows           = $rows.ToArray()
        PassThru       = $true
        WhatIf         = $false
        Confirm        = $false
    }
    if ($PSBoundParameters.ContainsKey('DueDate')) { $invoiceParams.DueDate = $DueDate }
    $invoice = New-LedgerInvoice @invoiceParams

    try {
        foreach ($e in $selected) { $e.InvoiceNumber = [int]$invoice.InvoiceNumber }
        $months = @($selected | ForEach-Object { $_.Date.ToString('yyyy-MM') } | Select-Object -Unique)
        Save-LedgerTimeEntries -JournalPath $JournalPath -Entries $entries -Months $months
    }
    catch {
        # Without the link the time would be invoiced twice; undo the invoice.
        Remove-Item -LiteralPath $invoice.FilePath -Force -ErrorAction SilentlyContinue
        throw
    }

    if ($PassThru) { $invoice }
}