Varibill.SourceCollector.Core.psm1

<#
.Synopsis
    Collect and send Varibill billing data via PowerShell (JSON based).
 
.Description
    Builds JSON payloads for Unrated or Rated source data records and posts them to the Varibill API.
    Supports saving of generated JSON payloads and API responses. Splits records into multiple files if needed.
    Unsuccessful posts leave files as .rdy for retry.
 
.Example
    # Create an UnratedSourceData instance
    $unrated = New-UnratedSourceData -ApiUrl "https://qasc.varibill.com/api/v1/SourceCollectors/Unrated" `
        -TenantKey ([guid]::NewGuid()) -SourceCollectorKey ([guid]::NewGuid()) -SourceCollectorSecret "secret" -Verbose
 
    # Add multiple records
    1..2500 | ForEach-Object {
        $unrated.AddUnratedSourceData("Client$_", "Prod", "Rec$_", "UID$_", (Get-Date), $_, "Tag$_")
    }
 
    # Post records (saves .rdy files and posts them)
    $unrated.PostSourceData -Verbose
 
    # Create a RatedSourceData instance
    $rated = New-RatedSourceData -ApiUrl "https://qasc.varibill.com/api/v1/SourceCollectors/Rated" `
        -TenantKey ([guid]::NewGuid()) -SourceCollectorKey ([guid]::NewGuid()) -SourceCollectorSecret "secret" -Verbose
 
    # Add multiple records
    1..1500 | ForEach-Object {
        $rated.AddRatedSourceData("Client$_", "Prod", "Rec$_", "UID$_", (Get-Date), $_, $_*2, $_*1.5, "TAG$_")
    }
 
    # Post records
    $rated.PostSourceData -Verbose
#>


enum SourceDataType { unrated; rated }
enum FileExtension { json; rdy }

Class SourceDataBase {
    hidden [string]$ApiUrl
    hidden [Guid]$TenantKey
    hidden [Guid]$SourceCollectorKey
    hidden [string]$SourceCollectorSecret
    hidden [string]$SourceCollectorPath
    hidden [System.Collections.ArrayList]$Records
    hidden [PSCredential]$Credential
    hidden [int]$MaxRecordsPerFile
    hidden [SourceDataType]$SourceDataType

    SourceDataBase(
        [string]$ApiUrl,
        [Guid]$TenantKey,
        [Guid]$SourceCollectorKey,
        [string]$SourceCollectorSecret,
        [string]$SourceCollectorPath = $null,
        [int]$MaxRecordsPerFile = 10000,
        [SourceDataType]$SourceDataType
    ) {
        Write-Verbose "Initializing SourceDataBase..."
        $this.ApiUrl = $ApiUrl
        $this.TenantKey = $TenantKey
        $this.SourceCollectorKey = $SourceCollectorKey
        $this.SourceCollectorSecret = $SourceCollectorSecret
        $this.MaxRecordsPerFile = $MaxRecordsPerFile
        $this.SourceDataType = $SourceDataType

        if (-not $SourceCollectorPath) {
            $this.SourceCollectorPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "Varibill"
            $this.SourceCollectorPath = Join-Path -Path $this.SourceCollectorPath -ChildPath "$TenantKey"
            $this.SourceCollectorPath = Join-Path -Path $this.SourceCollectorPath -ChildPath "$SourceCollectorKey"
            Write-Verbose "Using temp path: $($this.SourceCollectorPath)"
        } else {        
            $this.SourceCollectorPath = $SourceCollectorPath
        }

        if (-not (Test-Path $this.SourceCollectorPath)) {
            Write-Verbose "Creating directory: $($this.SourceCollectorPath)"
            New-Item -ItemType Directory -Path $this.SourceCollectorPath -Force | Out-Null
        }
        $subDirs = @("$($this.SourceDataType.ToString())", "log")
        $subDirs | ForEach-Object {
            $path = Join-Path -Path $this.SourceCollectorPath -ChildPath $_
            if (-not (Test-Path $path)) {
                Write-Verbose "Creating directory: $path"
                New-Item -ItemType Directory -Path $path -Force | Out-Null
            }
        }

        $this.Records = [System.Collections.ArrayList]::new()

        $this.Credential = New-Object System.Management.Automation.PSCredential(
            "$TenantKey#$SourceCollectorKey",
            (ConvertTo-SecureString $SourceCollectorSecret -AsPlainText -Force)
        )

        Write-Verbose "SourceDataBase initialized with TenantKey=$TenantKey, SourceCollectorKey=$SourceCollectorKey"
    }

    hidden [string]GetFileName([FileExtension]$extension) {
        $timestamp = (Get-Date).ToString("yyyyMMdd_HHmmss")
        $guid = [guid]::NewGuid().ToString()
        return "$timestamp`_$guid.$($extension.ToString())"
    }

    SaveData() {
        if (-not $this.Records -or $this.Records.Count -eq 0) { Write-Verbose "No records to save as .rdy"; return}

        $chunks = [Math]::Ceiling($this.Records.Count / $this.MaxRecordsPerFile)
        Write-Verbose "Saving $($this.Records.Count) records into $chunks .rdy file(s)."

        for ($i = 0; $i -lt $chunks; $i++) {
            $start = $i * $this.MaxRecordsPerFile
            $count = [Math]::Min($this.MaxRecordsPerFile, $this.Records.Count - $start)
            $subset = $this.Records[$start..($start + $count - 1)]

            $fileName = Join-Path $this.SourceCollectorPath $this.SourceDataType.ToString()
            $fileName = Join-Path $fileName $this.GetFileName([FileExtension]::Rdy)
            Write-Verbose "Writing $count records to $fileName"
            $subset | ConvertTo-Json -Depth 5 | Set-Content -Path $fileName -Encoding UTF8
        }

        $this.Records.Clear()
        return
    }

    PostSourceData() {
        Write-Verbose "PostSourceData called → saving to .rdy files first..."
        $this.SaveData()

        $filePath = Join-Path $this.SourceCollectorPath $this.SourceDataType.ToString()

        Write-Verbose "Now posting saved .rdy files..."
        $files = Get-ChildItem -Path $filePath -Filter "*.rdy" -File
        if (-not $files) {
            Write-Verbose "No .rdy files found in $filePath"
            return
        }

        foreach ($file in $files) {
            Write-Verbose "Processing file: $($file.FullName)"
            $jsonBody = Get-Content -Path $file.FullName -Raw
            $headers = @{ "Content-Type" = "application/json" }

            # Invoke-WebRequest to capture StatusCode
            $response = Invoke-WebRequest -Uri $this.ApiUrl -Method Post -Body $jsonBody -Headers $headers -Authentication Basic -Credential $this.Credential -ErrorAction Stop -RetryIntervalSec 5 -MaximumRetryCount 3

            $fileName = Join-Path $this.SourceCollectorPath "log"
            $fileName = Join-Path $fileName $this.GetFileName([FileExtension]::json)
            $response.Content | Out-File -FilePath $fileName -Encoding utf8

            Write-Verbose "HTTP Status Code: $($response.StatusCode)"
            Write-Verbose "Response content: $($response.Content)"

            if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 300) {                        
                Write-Verbose "Post successful."
                $newName = $file.FullName -replace '\.rdy$', '.json'
                if (Test-Path $newName) { 
                    Remove-Item $newName -Force 
                }
                Rename-Item -Path $file.FullName -NewName $newName
                Write-Verbose "Upload successful for $($file.Name). Renamed to $(Split-Path $newName -Leaf)"
            } else {
                Write-Warning "Post failed with HTTP status $($response.StatusCode)"
            }
        }

        return
    }
}

Class UnratedSourceData : SourceDataBase {
    UnratedSourceData([string]$ApiUrl,[Guid]$TenantKey,[Guid]$SourceCollectorKey,[string]$SourceCollectorSecret,[string]$SourceCollectorPath=$null,[int]$MaxRecordsPerFile=1000)
        : base($ApiUrl,$TenantKey,$SourceCollectorKey,$SourceCollectorSecret,$SourceCollectorPath,$MaxRecordsPerFile,[SourceDataType]::Unrated) { 
            Write-Verbose "UnratedSourceData instance created."
        }

    AddUnratedSourceData([string]$ClientIdentifier,[string]$ProductIdentifier,[string]$RecordIdentifier,[string]$UniqueIdentifier,[datetime]$LastSeenDate,[decimal]$Quantity,[string]$Tag="") {
        Write-Verbose "Adding UnratedSourceData record for ClientIdentifier=$ClientIdentifier"
        $record = [PSCustomObject]@{
            ClientIdentifier=$ClientIdentifier; ProductIdentifier=$ProductIdentifier; RecordIdentifier=$RecordIdentifier; UniqueIdentifier=$UniqueIdentifier; LastSeenDate=$LastSeenDate.ToString("o"); Quantity=$Quantity; Tag=$Tag
        }
        [void]$this.Records.Add($record)
    }
}

Class RatedSourceData : SourceDataBase {
    RatedSourceData([string]$ApiUrl,[Guid]$TenantKey,[Guid]$SourceCollectorKey,[string]$SourceCollectorSecret,[string]$SourceCollectorPath=$null,[int]$MaxRecordsPerFile=1000)
        : base($ApiUrl,$TenantKey,$SourceCollectorKey,$SourceCollectorSecret,$SourceCollectorPath,$MaxRecordsPerFile,[SourceDataType]::Rated) { 
            Write-Verbose "RatedSourceData instance created."             
        }

    AddRatedSourceData([string]$ClientIdentifier,[string]$ProductIdentifier,[string]$RecordIdentifier,[string]$UniqueIdentifier,[datetime]$LastSeenDate,[decimal]$Quantity,[decimal]$Total=0,[decimal]$Cost=0,[string]$Tag="") 
    {
        Write-Verbose "Adding RatedSourceData record for ClientIdentifier=$ClientIdentifier"
        $record = [PSCustomObject]@{
            ClientIdentifier=$ClientIdentifier; ProductIdentifier=$ProductIdentifier; RecordIdentifier=$RecordIdentifier; UniqueIdentifier=$UniqueIdentifier; LastSeenDate=$LastSeenDate.ToString("o"); Quantity=$Quantity; Total=$Total; Cost=$Cost; Tag=$Tag
        }
        [void]$this.Records.Add($record)
    }
}

Function New-UnratedSourceData {
    [CmdletBinding()] Param(
        [Parameter(Mandatory)][string]$ApiUrl,
        [Parameter(Mandatory)][string]$TenantKey,
        [Parameter(Mandatory)][string]$SourceCollectorKey,
        [Parameter(Mandatory)][string]$SourceCollectorSecret,
        [string]$SourceCollectorPath=$null,
        [int]$MaxRecordsPerFile=1000
    )
    Write-Verbose "Creating new UnratedSourceData instance (MaxRecordsPerFile=$MaxRecordsPerFile)..."
    return [UnratedSourceData]::new($ApiUrl,$TenantKey,$SourceCollectorKey,$SourceCollectorSecret,$SourceCollectorPath,$MaxRecordsPerFile)
}

Function New-RatedSourceData {
    [CmdletBinding()] Param(
        [Parameter(Mandatory)][string]$ApiUrl,
        [Parameter(Mandatory)][string]$TenantKey,
        [Parameter(Mandatory)][string]$SourceCollectorKey,
        [Parameter(Mandatory)][string]$SourceCollectorSecret,
        [string]$SourceCollectorPath=$null,
        [int]$MaxRecordsPerFile=1000
    )
    Write-Verbose "Creating new RatedSourceData instance (MaxRecordsPerFile=$MaxRecordsPerFile)..."
    return [RatedSourceData]::new($ApiUrl,$TenantKey,$SourceCollectorKey,$SourceCollectorSecret,$SourceCollectorPath,$MaxRecordsPerFile)
}