Public/Export-LedgerArchive.ps1

<#
.SYNOPSIS
Exports a fiscal year as a self-contained archive package for long-term storage.

.DESCRIPTION
Creates a package that keeps the fiscal year's accounting records (räkenskapsinformation)
readable without PSLedger for the seven years the Swedish Bookkeeping Act
(bokföringslagen) requires. By default the package is one zip file,
'<JournalName>_<FiscalYear>_archive.zip', containing a folder with the same name:

  README.txt what the package contains and how to verify it (in Swedish)
  manifest.txt company, fiscal year, PSLedger version, integrity status and
                   the year's final chain hash
  checksums.txt SHA-256 of every other file, in the format of 'sha256sum -c'
  sie/ the year as a SIE 4 file
  reports/ grundbok, huvudbok, saldobalans, resultaträkning, balansräkning
                   and momsrapport as .txt and .pdf, and the annual report as
                   .md, .docx and .pdf
  journal/ a PSLedger journal with only this fiscal year: the registers,
                   the fiscal-year directory (verifications, attachments, documents,
                   integrity.txt), and the customer invoices, supplier invoices,
                   payslips and bank statements that belong to the year

Customer and supplier invoices are included when their invoice date is in the
year, they are booked in the year or a payment is booked in the year. Payslips are
included by pay date or booking, bank statements when their period overlaps the
year. Recurring entries, time reporting and extensions are not included.

Before exporting, the fiscal year must be closed and its integrity chain intact
(see Test-LedgerIntegrity). A year without a chain is exported with a warning.
A report that cannot be produced (for example an annual report without the
required company information) is skipped with a warning and listed in the manifest.

Use Test-LedgerArchive to verify a package later.

.PARAMETER JournalPath
The path to an existing journal directory. If omitted, uses the current journal.

.PARAMETER FiscalYear
The fiscal year to archive. If omitted, uses the latest fiscal year.
Accepts pipeline input from fiscal year objects.

.PARAMETER DestinationPath
The directory to write the package to. Defaults to an 'archive' folder next to
the journal directory. Created if it does not exist.

.PARAMETER AsDirectory
Write the package as a directory instead of a zip file.

.PARAMETER Force
Export even if the fiscal year is open (the package is marked as preliminary)
or its integrity check fails (the result is recorded in the manifest), and
overwrite an existing package.

.EXAMPLE
Export-LedgerArchive -JournalPath .\MinFirma.ledger -FiscalYear '2024-01_2024-12'

Creates .\archive\MinFirma_2024-01_2024-12_archive.zip next to Min Firma AB's journal.

.EXAMPLE
Get-LedgerFiscalYear -JournalPath .\Konsult.ledger | Where-Object Status -eq 'Closed' |
    Export-LedgerArchive -JournalPath .\Konsult.ledger -DestinationPath E:\Arkiv

Archives every closed fiscal year of Konsult AB to an external disk.
#>

function Export-LedgerArchive {
    [OutputType([System.IO.FileSystemInfo])]
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter()]
        [string]$JournalPath,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('Name')]
        [string]$FiscalYear,

        [Parameter()]
        [string]$DestinationPath,

        [switch]$AsDirectory,

        [switch]$Force
    )
    process {
        $JournalPath = Resolve-LedgerJournalPath -JournalPath $JournalPath
        $FiscalYear = Resolve-LedgerFiscalYear -FiscalYear $FiscalYear -JournalPath $JournalPath

        $journalDir = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($JournalPath).TrimEnd('\', '/')
        $yearDir = Join-Path $journalDir $FiscalYear
        if (-not (Test-Path -LiteralPath $yearDir -PathType Container)) {
            throw "Fiscal year not found: $FiscalYear"
        }
        $year = Get-LedgerFiscalYear -JournalPath $journalDir | Where-Object { $_.Name -eq $FiscalYear }
        if (-not $year) {
            throw "Fiscal year year.txt missing for $FiscalYear"
        }
        $preliminary = $year.Status -ne 'Closed'
        if ($preliminary -and -not $Force) {
            throw "Fiscal year $FiscalYear is open. Close it with Close-LedgerFiscalYear, or use -Force to export a preliminary archive."
        }

        $integrity = Test-LedgerIntegrity -JournalPath $journalDir -FiscalYear $FiscalYear
        if ($integrity.Status -eq 'Invalid') {
            $summary = @($integrity.Issues | Select-Object -First 5 | ForEach-Object { "$($_.Kind) $($_.Key): $($_.Problem)" }) -join '; '
            if (-not $Force) {
                throw "The integrity check of $FiscalYear failed ($summary). Run Test-LedgerIntegrity for details, or use -Force to archive anyway."
            }
            Write-Warning "The integrity check of $FiscalYear failed ($summary). Archiving anyway because -Force was given."
        }
        elseif ($integrity.Status -eq 'Unsealed') {
            Write-Warning "Fiscal year $FiscalYear has no integrity chain. Seal it with Protect-LedgerFiscalYear before archiving to include one."
        }

        $baseName = [System.IO.Path]::GetFileNameWithoutExtension($journalDir)
        $packageName = "${baseName}_${FiscalYear}_archive"
        if (-not $DestinationPath) {
            $DestinationPath = Join-Path (Split-Path $journalDir -Parent) 'archive'
        }
        $destDir = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($DestinationPath)
        $target = if ($AsDirectory) { Join-Path $destDir $packageName } else { Join-Path $destDir "$packageName.zip" }

        if ((Test-Path -LiteralPath $target) -and -not $Force) {
            throw "Archive already exists: $target. Use -Force to overwrite."
        }

        if (-not $PSCmdlet.ShouldProcess($target, "Export archive of fiscal year $FiscalYear")) {
            return
        }

        if (-not (Test-Path -LiteralPath $destDir -PathType Container)) {
            New-Item -ItemType Directory -Path $destDir -Force | Out-Null
        }

        $staging = Join-Path $destDir ".tmp_$([guid]::NewGuid().ToString('N'))"
        try {
            $root = Join-Path $staging $packageName
            $utf8 = [System.Text.UTF8Encoding]::new($false)
            foreach ($dir in 'sie', 'reports', 'journal') {
                New-Item -ItemType Directory -Path (Join-Path $root $dir) -Force | Out-Null
            }

            # --- journal/: a journal holding only this fiscal year ------------
            $archJournal = Join-Path $root 'journal'
            foreach ($file in 'journal.txt', 'accounts.txt', 'dimensions.txt', 'objects.txt', 'customers.txt', 'suppliers.txt', 'employees.txt') {
                $src = Join-Path $journalDir $file
                if (Test-Path -LiteralPath $src -PathType Leaf) {
                    Copy-Item -LiteralPath $src -Destination (Join-Path $archJournal $file)
                }
            }
            Copy-Item -LiteralPath $yearDir -Destination (Join-Path $archJournal $FiscalYear) -Recurse
            Get-ChildItem -LiteralPath (Join-Path $archJournal $FiscalYear) -Recurse -File -Filter '.tmp_*' -Force |
                Remove-Item -Force

            $start = [datetime]$year.StartDate
            $end = [datetime]$year.EndDate
            $inYear = { param($date) $null -ne $date -and ([datetime]$date) -ge $start -and ([datetime]$date) -le $end }
            $copyDocument = {
                param($sourcePath, $subDir)
                $dest = Join-Path $archJournal $subDir
                if (-not (Test-Path -LiteralPath $dest)) { New-Item -ItemType Directory -Path $dest | Out-Null }
                Copy-Item -LiteralPath $sourcePath -Destination (Join-Path $dest (Split-Path $sourcePath -Leaf))
            }

            $invoices = 0
            foreach ($inv in @(Get-LedgerInvoice -JournalPath $journalDir)) {
                if ((& $inYear $inv.InvoiceDate) -or $inv.BookedFiscalYear -eq $FiscalYear -or
                    @($inv.Payments | Where-Object { $_.FiscalYear -eq $FiscalYear }).Count -gt 0) {
                    & $copyDocument $inv.FilePath 'invoices'
                    $invoices++
                }
            }
            $supplierInvoices = 0
            foreach ($inv in @(Get-LedgerSupplierInvoice -JournalPath $journalDir)) {
                if ((& $inYear $inv.InvoiceDate) -or $inv.BookedFiscalYear -eq $FiscalYear -or
                    @($inv.Payments | Where-Object { $_.FiscalYear -eq $FiscalYear }).Count -gt 0) {
                    & $copyDocument $inv.FilePath 'supplierinvoices'
                    $supplierInvoices++
                }
            }
            $payslips = 0
            foreach ($slip in @(Get-LedgerPayslip -JournalPath $journalDir)) {
                if ((& $inYear $slip.PayDate) -or $slip.BookedFiscalYear -eq $FiscalYear) {
                    & $copyDocument $slip.FilePath 'payslips'
                    $payslips++
                }
            }
            $statements = 0
            foreach ($stmt in @(Get-LedgerBankStatement -JournalPath $journalDir)) {
                $from = if ($stmt.FromDate) { [datetime]$stmt.FromDate } else { [datetime]$stmt.ImportedDate }
                $to = if ($stmt.ToDate) { [datetime]$stmt.ToDate } else { $from }
                $file = Join-Path $journalDir 'bank' ('stmt{0:D4}.txt' -f [int]$stmt.StatementNumber)
                if ($from -le $end -and $to -ge $start -and (Test-Path -LiteralPath $file -PathType Leaf)) {
                    & $copyDocument $file 'bank'
                    $statements++
                }
            }

            # --- sie/ and reports/ ---------------------------------------------
            Export-LedgerSie -JournalPath $journalDir -FiscalYear $FiscalYear -Path (Join-Path $root 'sie' "${baseName}_${FiscalYear}.se") -Confirm:$false -WhatIf:$false

            $journal = Get-LedgerJournal -Path $journalDir
            $version = [string]$MyInvocation.MyCommand.Module.Version
            $created = [datetime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ', [cultureinfo]::InvariantCulture)
            $context = [pscustomobject]@{
                CompanyName = $journal.Name; OrgNumber = $journal.OrgNumber
                StartDate = $start.ToString('yyyy-MM-dd'); EndDate = $end.ToString('yyyy-MM-dd')
                Preliminary = $preliminary; Created = $created; Version = $version
            }

            $entries = @(Get-LedgerEntry -JournalPath $journalDir -FiscalYear $FiscalYear)
            $balances = @(Get-LedgerBalance -JournalPath $journalDir -FiscalYear $FiscalYear)
            $accountNames = @{}
            foreach ($a in @(Get-LedgerAccount -JournalPath $journalDir)) { $accountNames[[string]$a.AccountNumber] = $a.AccountName }

            $reportsDir = Join-Path $root 'reports'
            $omitted = [System.Collections.Generic.List[string]]::new()
            $reports = [ordered]@{
                grundbok        = { Build-LedgerArchiveJournalBlocks -Entries $entries -AccountNames $accountNames -Context $context }
                huvudbok        = { Build-LedgerArchiveGeneralLedgerBlocks -Entries $entries -Balances $balances -Context $context }
                saldobalans     = { Build-LedgerArchiveTrialBalanceBlocks -Balances $balances -Context $context }
                resultatrakning = {
                    Build-LedgerArchiveStatementBlocks -Title 'Resultaträkning' -Context $context `
                        -Lines @(Get-LedgerIncomeStatement -JournalPath $journalDir -FiscalYear $FiscalYear) `
                        -SumGroups 'OperatingResult', 'ResultAfterFinancialItems', 'NetResult'
                }
                balansrakning   = {
                    Build-LedgerArchiveStatementBlocks -Title 'Balansräkning' -Context $context `
                        -Lines @(Get-LedgerBalanceSheet -JournalPath $journalDir -FiscalYear $FiscalYear) `
                        -SumGroups 'TotalAssets', 'TotalEquityAndLiabilities' -NegateSections 'EquityAndLiabilities' `
                        -Note 'Eget kapital och skulder visas med positivt tecken.'
                }
                momsrapport     = {
                    Build-LedgerArchiveVatBlocks -Context $context `
                        -Lines @(Get-LedgerVatReport -JournalPath $journalDir -FiscalYear $FiscalYear -FromDate $start -ToDate $end)
                }
            }
            foreach ($name in $reports.Keys) {
                try {
                    $blocks = @(& $reports[$name])
                    Write-LedgerArchiveReport -Block $blocks -BasePath (Join-Path $reportsDir $name)
                }
                catch {
                    Write-Warning "Skipped report '$name': $($_.Exception.Message)"
                    $omitted.Add("reports/$name`: $($_.Exception.Message)")
                }
            }
            try {
                $blocks = @(Build-LedgerAnnualReportBlocks -JournalPath $journalDir -FiscalYear $FiscalYear)
                [System.IO.File]::WriteAllText((Join-Path $reportsDir 'arsredovisning.md'), (ConvertTo-LedgerReportMarkdown -Block $blocks), $utf8)
                ConvertTo-LedgerReportDocx -Block $blocks -Path (Join-Path $reportsDir 'arsredovisning.docx')
                ConvertTo-LedgerReportPdf -Block $blocks -Path (Join-Path $reportsDir 'arsredovisning.pdf')
            }
            catch {
                Write-Warning "Skipped the annual report: $($_.Exception.Message)"
                Get-ChildItem -LiteralPath $reportsDir -Filter 'arsredovisning.*' | Remove-Item -Force
                $omitted.Add("reports/arsredovisning: $($_.Exception.Message)")
            }

            # --- manifest.txt, README.txt, checksums.txt -----------------------
            $manifest = [System.Collections.Generic.List[string]]::new()
            $manifest.Add('; PSLedger archive manifest')
            $fields = [ordered]@{
                ArchiveFormat    = $script:LedgerArchiveFormatVersion
                Journal          = $journal.Name
                OrgNumber        = $journal.OrgNumber
                FiscalYear       = $FiscalYear
                StartDate        = $context.StartDate
                EndDate          = $context.EndDate
                YearStatus       = $year.Status
                Preliminary      = $preliminary
                Created          = $created
                PSLedgerVersion  = $version
                SchemaVersion    = $journal.SchemaVersion
                IntegrityStatus  = $integrity.Status
                ChainHash        = $integrity.ChainHash
                Verifications    = $entries.Count
                Attachments      = @(Get-ChildItem -LiteralPath $yearDir -Directory -Filter 'ver*' | Get-ChildItem -File).Count
                Invoices         = $invoices
                SupplierInvoices = $supplierInvoices
                Payslips         = $payslips
                BankStatements   = $statements
            }
            foreach ($key in $fields.Keys) {
                $manifest.Add("$key`t$(ConvertTo-LedgerTextField ([string]$fields[$key]))")
            }
            foreach ($o in $omitted) { $manifest.Add("Omitted`t$(ConvertTo-LedgerTextField $o)") }
            [System.IO.File]::WriteAllText((Join-Path $root $script:LedgerArchiveManifestFile), (($manifest -join "`n") + "`n"), $utf8)

            $prelimText = if ($preliminary) { "`nOBS: räkenskapsåret var inte stängt när arkivet skapades. Arkivet är preliminärt.`n" } else { '' }
            $readme = @"
Arkiv för räkenskapsår $FiscalYear ($($context.StartDate) - $($context.EndDate))
$($journal.Name) $($journal.OrgNumber)
Skapat $created med PSLedger $version.
$prelimText
Innehåll
  manifest.txt uppgifter om arkivet (företag, år, integritetskontroll, kedjans hash)
  checksums.txt SHA-256 för alla övriga filer
  sie/ räkenskapsåret som SIE 4-fil (kan läsas in i andra bokföringsprogram)
  reports/ grundbok, huvudbok, saldobalans, resultaträkning, balansräkning och
                 momsrapport (.txt och .pdf) samt årsredovisningen (.md, .docx, .pdf)
  journal/ bokföringen i PSLedgers textformat: verifikationer (verNNNN.txt),
                 bilagor (verNNNN/), årets dokument, integrity.txt, register,
                 fakturor, leverantörsfakturor, lönebesked och bankutdrag för året

Alla textfiler är UTF-8. Filformatet beskrivs i docs/File-format.md i PSLedger
(https://github.com/hansEricG/PSLedger).

Kontrollera arkivet
  PowerShell med PSLedger: Test-LedgerArchive -Path <arkiv>
  Linux/macOS: sha256sum -c checksums.txt
"@

            [System.IO.File]::WriteAllText((Join-Path $root 'README.txt'), $readme.Replace("`r`n", "`n"), $utf8)

            $checksums = foreach ($rel in (Get-LedgerArchiveRelativeFile -Root $root)) {
                "$(Get-LedgerArchiveFileHash -Path (Join-Path $root $rel)) $rel"
            }
            [System.IO.File]::WriteAllText((Join-Path $root $script:LedgerArchiveChecksumFile), (($checksums -join "`n") + "`n"), $utf8)

            # --- move into place -----------------------------------------------
            if ($AsDirectory) {
                if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target -Recurse -Force }
                Move-Item -LiteralPath $root -Destination $target
            }
            else {
                $tmpZip = Join-Path $staging "$packageName.zip"
                [System.IO.Compression.ZipFile]::CreateFromDirectory($root, $tmpZip, [System.IO.Compression.CompressionLevel]::Optimal, $true)
                [System.IO.File]::Move($tmpZip, $target, $true)
            }
        }
        finally {
            if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }
        }

        Get-Item -LiteralPath $target
    }
}