UpBank.psm1

$Global:token = $null
$Global:profileName = $null
$Global:APIVersion = $null

$UpBankConfigurationFile = Join-Path $env:LOCALAPPDATA UpBankConfiguration.clixml
if (Test-Path $UpBankConfigurationFile) {
    $script:UpBankConfiguration = Import-Clixml $UpBankConfigurationFile

    if ($UpBankConfiguration.APIVersion) {
        $Global:APIVersion = $UpBankConfiguration.APIVersion
    }

    if ($UpBankConfiguration.defaultProfile) {
        $profile = $UpBankConfiguration.defaultProfile
        $Global:token = [System.Runtime.InteropServices.marshal]::PtrToStringAuto([System.Runtime.InteropServices.marshal]::SecureStringToBSTR($UpBankConfiguration.$profile.PersonalAccessToken))
    }
}
else {
    $script:UpBankConfiguration = @{
        APIVersion = "v1"
    }
    $Global:APIVersion = "v1"
}

if (-not $Global:APIVersion) {
    $Global:APIVersion = "v1"
}

# --- Private helpers (not exported) ---

function Invoke-UpBankGet {
    <#
.SYNOPSIS
Private helper. Performs a GET request against the Up Bank API with retry on rate limiting (HTTP 429).
#>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [String] $Uri
    )

    $attempt = 0
    while ($true) {
        try {
            return Invoke-RestMethod -Method Get `
                -Uri $Uri `
                -Headers @{Authorization = "Bearer $($Global:token)" }
        }
        catch {
            $statusCode = 0
            if ($_.Exception.Response) {
                $statusCode = [int]$_.Exception.Response.StatusCode
            }
            $attempt++
            if ($statusCode -eq 429 -and $attempt -lt 3) {
                $delay = [int][Math]::Pow(2, $attempt)
                try {
                    $retryAfter = $null
                    $headers = $_.Exception.Response.Headers
                    if ($headers -is [System.Net.WebHeaderCollection]) {
                        # Windows PowerShell 5.1
                        $retryAfter = $headers['Retry-After']
                    }
                    elseif ($headers.Contains('Retry-After')) {
                        # PowerShell 7+ (HttpResponseHeaders)
                        $retryAfter = ($headers.GetValues('Retry-After') | Select-Object -First 1)
                    }
                    if ($retryAfter) {
                        $delay = [int]$retryAfter
                    }
                }
                catch {}
                Write-Verbose "Up Bank API rate limit reached (429). Retrying in $($delay) seconds."
                Start-Sleep -Seconds $delay
            }
            else {
                throw
            }
        }
    }
}

function Invoke-UpBankRequest {
    <#
.SYNOPSIS
Private helper. Builds an Up Bank API request URI, validates the Personal Access Token
and (optionally) follows the links.next cursor to paginate results.

.PARAMETER Path
Relative API path. e.g 'transactions', 'accounts/<id>/transactions', 'util/ping'

.PARAMETER Query
Ordered dictionary of query string parameters. Keys are used literally (e.g 'page[size]'), values are URL encoded.

.PARAMETER Paginate
Follow the links.next cursor returned by the API and emit each record to the pipeline.

.PARAMETER First
With -Paginate, stop after this many records. 0 = return everything.
#>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [String] $Path,
        [System.Collections.IDictionary] $Query,
        [switch] $Paginate,
        [int] $First = 0
    )

    if ([string]::IsNullOrEmpty($Global:token)) {
        throw "No Personal Access Token found in Up Bank Configuration. Use Set-UpBankCredential and Save-UpBankConfiguration to securely store your Up Bank Personal Access Token."
    }

    $uri = "https://api.up.com.au/api/$($Global:APIVersion)/$($Path)"
    if ($Query -and $Query.Count -gt 0) {
        $pairs = foreach ($key in $Query.Keys) {
            "{0}={1}" -f $key, [uri]::EscapeDataString([string]$Query[$key])
        }
        $uri = "$($uri)?$($pairs -join '&')"
    }
    Write-Verbose $uri

    if (-not $Paginate) {
        return Invoke-UpBankGet -Uri $uri
    }

    $emitted = 0
    $response = Invoke-UpBankGet -Uri $uri
    while ($true) {
        if ($response.data) {
            foreach ($item in @($response.data)) {
                $item
                $emitted++
                if ($First -gt 0 -and $emitted -ge $First) {
                    return
                }
            }
        }
        if (-not $response.links.next) {
            return
        }
        # links.next is a complete cursor URL that carries all filters
        $response = Invoke-UpBankGet -Uri $response.links.next
    }
}

function Format-UpBankDate {
    <#
.SYNOPSIS
Private helper. Formats a DateTime as RFC-3339 for the Up Bank API filter[since]/filter[until] parameters.
#>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [DateTime] $Date
    )

    if ($Date.Kind -eq [System.DateTimeKind]::Unspecified) {
        $Date = [DateTime]::SpecifyKind($Date, [System.DateTimeKind]::Local)
    }
    return $Date.ToString("yyyy-MM-ddTHH:mm:ssK")
}

# --- Exported cmdlets ---

function Set-UpBankCredential {
    <#
.SYNOPSIS
Sets the default Up Bank API credentials.

.DESCRIPTION
Sets the default Up Bank API credentials. Configuration values can
be securely saved to a user's profile using Save-UpBankConfiguration.

.PARAMETER Credential
A standard Powershell Credential object.

.EXAMPLE
$cred = Get-Credential -Message 'Custom message...' -UserName 'Custom Username'
Set-UpBankCredential -Credential $cred

.LINK
http://darrenjrobinson.com/

#>


    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [PSCredential]$Credential
    )

    $script:UpBankConfiguration[$Credential.UserName] = @{PersonalAccessToken = $Credential.Password }
    $Global:token = [System.Runtime.InteropServices.marshal]::PtrToStringAuto([System.Runtime.InteropServices.marshal]::SecureStringToBSTR($Credential.Password))
    $Global:profileName = $Credential.UserName
}


function Switch-UpBankProfile {
    <#
.SYNOPSIS
Changes the Up Bank Account Credentials used to a different Up Bank Profile.

.DESCRIPTION
Changes the Up Bank Account Credentials used to a different Up Bank Profile.
Optionally sets the default Up Bank API credentials to a different profile.
Configuration values can be securely saved to a user's profile using Save-UpBankConfiguration.

.PARAMETER profile
Profile Name to switch too.

.PARAMETER default
(Optional) Set the profile being switched to as the new Default Profile that will be loaded on Module Load.

.EXAMPLE
Switch-UpBankProfile -profile Darren

.EXAMPLE
Switch-UpBankProfile -profile Darren -default

.LINK
http://darrenjrobinson.com/

#>


    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [string]$profile,
        [Parameter(Mandatory = $false, Position = 0)]
        [switch]$default
    )

    if ($script:UpBankConfiguration.$profile) {
        $Global:token = [System.Runtime.InteropServices.marshal]::PtrToStringAuto([System.Runtime.InteropServices.marshal]::SecureStringToBSTR($script:UpBankConfiguration.$profile.PersonalAccessToken))
        $Global:profileName = $profile

        if ($default) {
            $script:UpBankConfiguration.defaultProfile = $profile
            Export-Clixml -Path $UpBankConfigurationFile -InputObject $script:UpBankConfiguration
        }
    }
    else {
        Write-Error "No Profile with name $($profile) was found in the Up Bank Configuration file."
        return
    }
}


function Save-UpBankConfiguration {
    <#
.SYNOPSIS
    Saves default Up Bank configuration to a file in the current users Profile.

.DESCRIPTION
    Saves default Up Bank configuration to a file within the current
    users Profile. If it exists, this file is then read, each time the
    Up Bank Module is imported. Allowing settings to persist between
    sessions.

.EXAMPLE
    Save-UpBankConfiguration

.LINK
    http://darrenjrobinson.com/
#>


    [CmdletBinding()]
    param ([switch]$default)
    if ($default) { $script:UpBankConfiguration.defaultProfile = $Global:profileName }

    Export-Clixml -Path $UpBankConfigurationFile -InputObject $script:UpBankConfiguration

}

function Test-UpBankAPI {
    <#
.SYNOPSIS
Ping UpBank

.DESCRIPTION
Ping UpBank to validate the currently loaded Personal Access Token.

.EXAMPLE
Test-UpBankAPI

.LINK
http://darrenjrobinson.com/

#>


    [CmdletBinding()]
    param()

    $response = Invoke-UpBankRequest -Path "util/ping"
    return $response.meta
}

function Get-UpBankAccounts {
    <#
.SYNOPSIS
List UpBank Accounts

.DESCRIPTION
List UpBank Accounts. Retrieves all accounts, following pagination automatically.

.PARAMETER AccountType
(Optional) Only return accounts of this type. SAVER, TRANSACTIONAL or HOME_LOAN.

.PARAMETER OwnershipType
(Optional) Only return accounts with this ownership structure. INDIVIDUAL or JOINT.

.PARAMETER PageSize
The number of records to return in each page request. Defaults to 100 (the API maximum).

.EXAMPLE
Get-UpBankAccounts

.EXAMPLE
Get-UpBankAccounts -AccountType SAVER

.LINK
http://darrenjrobinson.com/

#>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [ValidateSet("SAVER", "TRANSACTIONAL", "HOME_LOAN")]
        [String] $AccountType,
        [Parameter(Mandatory = $false)]
        [ValidateSet("INDIVIDUAL", "JOINT")]
        [String] $OwnershipType,
        [Parameter(Mandatory = $false, Position = 0)]
        [ValidateRange(1, 100)]
        [int] $PageSize = 100
    )

    $query = [ordered]@{}
    $query['page[size]'] = $PageSize
    if ($AccountType) { $query['filter[accountType]'] = $AccountType }
    if ($OwnershipType) { $query['filter[ownershipType]'] = $OwnershipType }

    Invoke-UpBankRequest -Path "accounts" -Query $query -Paginate
}

function Get-UpBankAccount {
    <#
.SYNOPSIS
Get an UpBank Account

.DESCRIPTION
Get an UpBank Account

.PARAMETER id
(Required) The id of account to return

.EXAMPLE
Get-UpBankAccount -id af78838b-95a9-47f6-aa38-70d827af9539

.LINK
http://darrenjrobinson.com/

#>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [String] $id
    )

    $response = Invoke-UpBankRequest -Path "accounts/$($id)"
    return $response.data
}

function Get-UpBankTransactions {
    <#
.SYNOPSIS
Retrieve a list of all transactions across all accounts for the currently authenticated user.

.DESCRIPTION
Retrieve a list of all transactions across all accounts for the currently authenticated user.
Results are returned newest first. Pagination is followed automatically.

.PARAMETER Status
(Optional) Only return transactions with this status. HELD or SETTLED.

.PARAMETER Since
(Optional) The start date-time from which to return records. Accepts a DateTime or a parseable date string.

.PARAMETER Until
(Optional) The end date-time up to which to return records. Accepts a DateTime or a parseable date string.

.PARAMETER Category
(Optional) Only return transactions with this category id (parent or child). e.g groceries

.PARAMETER Tag
(Optional) Only return transactions with this exact tag.

.PARAMETER PageSize
The number of records to return in each page request. Defaults to 100 (the API maximum).

.PARAMETER First
The total number of records to return. Defaults to 100. Not valid with -All.

.PARAMETER All
Return every matching transaction, following pagination until exhausted.

.EXAMPLE
Get-UpBankTransactions -First 30

.EXAMPLE
Get-UpBankTransactions -Since '2025-07-01' -Until '2026-07-01' -All

.EXAMPLE
Get-UpBankTransactions -Status SETTLED -Category groceries -First 50

.LINK
http://darrenjrobinson.com/

#>


    [CmdletBinding(DefaultParameterSetName = "Limited")]
    param(
        [Parameter(Mandatory = $false)]
        [ValidateSet("HELD", "SETTLED")]
        [String] $Status,
        [Parameter(Mandatory = $false)]
        [DateTime] $Since,
        [Parameter(Mandatory = $false)]
        [DateTime] $Until,
        [Parameter(Mandatory = $false)]
        [String] $Category,
        [Parameter(Mandatory = $false)]
        [String] $Tag,
        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 100)]
        [int] $PageSize = 100,
        [Parameter(Mandatory = $false, ParameterSetName = "Limited")]
        [ValidateRange(1, [int]::MaxValue)]
        [int] $First = 100,
        [Parameter(Mandatory = $false, ParameterSetName = "All")]
        [switch] $All
    )

    $recordsToReturn = 0
    if (-not $All) { $recordsToReturn = $First }

    $size = $PageSize
    if ($recordsToReturn -gt 0 -and $recordsToReturn -lt $size) { $size = $recordsToReturn }

    $query = [ordered]@{}
    $query['page[size]'] = $size
    if ($Status) { $query['filter[status]'] = $Status }
    if ($PSBoundParameters.ContainsKey('Since')) { $query['filter[since]'] = Format-UpBankDate -Date $Since }
    if ($PSBoundParameters.ContainsKey('Until')) { $query['filter[until]'] = Format-UpBankDate -Date $Until }
    if ($Category) { $query['filter[category]'] = $Category }
    if ($Tag) { $query['filter[tag]'] = $Tag }

    Invoke-UpBankRequest -Path "transactions" -Query $query -Paginate -First $recordsToReturn
}

function Get-UpBankTransaction {
    <#
.SYNOPSIS
Retrieve a specific Up Bank Transaction.

.DESCRIPTION
Retrieve a specific Up Bank Transaction.

.PARAMETER id
(Required) ID of the transaction to return.

.EXAMPLE
Get-UpBankTransaction -id 167ae4de-084f-4ed0-85f7-152525f790fb

.LINK
http://darrenjrobinson.com/

#>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [String] $id
    )

    $response = Invoke-UpBankRequest -Path "transactions/$($id)"
    return $response.data
}


function Get-UpBankAccountTransactions {
    <#
.SYNOPSIS
Retrieve a list of all transactions for a specific account for the currently authenticated user.

.DESCRIPTION
Retrieve a list of all transactions for a specific account for the currently authenticated user.
Results are returned newest first. Pagination is followed automatically.
Accepts account objects from Get-UpBankAccounts via the pipeline.

.PARAMETER AccountId
(Required) Account ID to return transactions from.

.PARAMETER Status
(Optional) Only return transactions with this status. HELD or SETTLED.

.PARAMETER Since
(Optional) The start date-time from which to return records. Accepts a DateTime or a parseable date string.

.PARAMETER Until
(Optional) The end date-time up to which to return records. Accepts a DateTime or a parseable date string.

.PARAMETER Category
(Optional) Only return transactions with this category id (parent or child). e.g groceries

.PARAMETER Tag
(Optional) Only return transactions with this exact tag.

.PARAMETER PageSize
The number of records to return in each page request. Defaults to 100 (the API maximum).

.PARAMETER First
The total number of records to return. Defaults to 100. Not valid with -All.

.PARAMETER All
Return every matching transaction, following pagination until exhausted.

.EXAMPLE
Get-UpBankAccountTransactions -AccountId 4bda3ac4-034e-44bc-b761-8277ccbfe2ee

.EXAMPLE
Get-UpBankAccounts | Get-UpBankAccountTransactions -All -Since '2025-07-01'

.LINK
http://darrenjrobinson.com/

#>


    [CmdletBinding(DefaultParameterSetName = "Limited")]
    param(
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipelineByPropertyName = $true)]
        [Alias("accountID", "id")]
        [String] $AccountId,
        [Parameter(Mandatory = $false)]
        [ValidateSet("HELD", "SETTLED")]
        [String] $Status,
        [Parameter(Mandatory = $false)]
        [DateTime] $Since,
        [Parameter(Mandatory = $false)]
        [DateTime] $Until,
        [Parameter(Mandatory = $false)]
        [String] $Category,
        [Parameter(Mandatory = $false)]
        [String] $Tag,
        [Parameter(Mandatory = $false)]
        [ValidateRange(1, 100)]
        [int] $PageSize = 100,
        [Parameter(Mandatory = $false, ParameterSetName = "Limited")]
        [ValidateRange(1, [int]::MaxValue)]
        [int] $First = 100,
        [Parameter(Mandatory = $false, ParameterSetName = "All")]
        [switch] $All
    )

    process {
        $recordsToReturn = 0
        if (-not $All) { $recordsToReturn = $First }

        $size = $PageSize
        if ($recordsToReturn -gt 0 -and $recordsToReturn -lt $size) { $size = $recordsToReturn }

        $query = [ordered]@{}
        $query['page[size]'] = $size
        if ($Status) { $query['filter[status]'] = $Status }
        if ($PSBoundParameters.ContainsKey('Since')) { $query['filter[since]'] = Format-UpBankDate -Date $Since }
        if ($PSBoundParameters.ContainsKey('Until')) { $query['filter[until]'] = Format-UpBankDate -Date $Until }
        if ($Category) { $query['filter[category]'] = $Category }
        if ($Tag) { $query['filter[tag]'] = $Tag }

        Invoke-UpBankRequest -Path "accounts/$($AccountId)/transactions" -Query $query -Paginate -First $recordsToReturn
    }
}

function Get-UpBankCategories {
    <#
.SYNOPSIS
List UpBank Categories

.DESCRIPTION
List UpBank Categories. This endpoint is not paginated; all categories are returned.

.EXAMPLE
Get-UpBankCategories

.LINK
http://darrenjrobinson.com/

#>


    [CmdletBinding()]
    param()

    $response = Invoke-UpBankRequest -Path "categories"
    return $response.data
}

function Get-UpBankCategory {
    <#
.SYNOPSIS
Retrieve a specific Up Bank Category.

.DESCRIPTION
Retrieve a specific Up Bank Category.

.PARAMETER id
(Required) ID of the Category to return.

.EXAMPLE
Get-UpBankCategory -id technology

.LINK
http://darrenjrobinson.com/

#>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [String] $id
    )

    $response = Invoke-UpBankRequest -Path "categories/$($id)"
    return $response.data
}

function Get-UpBankTags {
    <#
.SYNOPSIS
List UpBank Tags

.DESCRIPTION
List UpBank Tags. Retrieves all tags, following pagination automatically.

.PARAMETER PageSize
The number of records to return in each page request. Defaults to 100 (the API maximum).

.EXAMPLE
Get-UpBankTags

.LINK
http://darrenjrobinson.com/

#>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false, Position = 0)]
        [ValidateRange(1, 100)]
        [int] $PageSize = 100
    )

    $query = [ordered]@{}
    $query['page[size]'] = $PageSize

    Invoke-UpBankRequest -Path "tags" -Query $query -Paginate
}

function ConvertTo-UpBankTransactionReport {
    <#
.SYNOPSIS
Flattens Up Bank transaction objects into flat objects suitable for Export-Csv.

.DESCRIPTION
Flattens the nested JSON:API transaction objects returned by Get-UpBankTransactions and
Get-UpBankAccountTransactions into flat PSCustomObjects (one row per transaction) suitable
for Export-Csv, Out-GridView or reporting. e.g providing transaction data to an accountant.

.PARAMETER Transaction
One or more Up Bank transaction objects. Accepts pipeline input.

.PARAMETER ResolveAccountNames
(Optional) Resolve account ids to account display names (adds an AccountName column).
Makes a single call to Get-UpBankAccounts.

.EXAMPLE
Get-UpBankTransactions -All -Since '2025-07-01' -Until '2026-07-01' | ConvertTo-UpBankTransactionReport | Export-Csv .\UpBank-FY2025-26.csv -NoTypeInformation

.EXAMPLE
Get-UpBankTransactions -First 50 | ConvertTo-UpBankTransactionReport -ResolveAccountNames | Out-GridView

.LINK
http://darrenjrobinson.com/

#>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [object[]] $Transaction,
        [Parameter(Mandatory = $false)]
        [switch] $ResolveAccountNames
    )

    begin {
        $accountNames = @{}
        if ($ResolveAccountNames) {
            foreach ($account in (Get-UpBankAccounts)) {
                $accountNames[$account.id] = $account.attributes.displayName
            }
        }
    }

    process {
        foreach ($txn in $Transaction) {
            $attributes = $txn.attributes
            $relationships = $txn.relationships

            $createdAt = ""
            if ($attributes.createdAt) { $createdAt = ([DateTime]$attributes.createdAt).ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") }
            $settledAt = ""
            if ($attributes.settledAt) { $settledAt = ([DateTime]$attributes.settledAt).ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") }

            $foreignAmount = $null
            $foreignCurrency = ""
            if ($attributes.foreignAmount) {
                $foreignAmount = [decimal]$attributes.foreignAmount.value
                $foreignCurrency = $attributes.foreignAmount.currencyCode
            }

            $category = ""
            if ($relationships.category.data) { $category = $relationships.category.data.id }
            $parentCategory = ""
            if ($relationships.parentCategory.data) { $parentCategory = $relationships.parentCategory.data.id }

            $tags = ""
            if ($relationships.tags.data) { $tags = (@($relationships.tags.data) | ForEach-Object { $_.id }) -join ";" }

            $accountId = ""
            if ($relationships.account.data) { $accountId = $relationships.account.data.id }
            $transferAccountId = ""
            if ($relationships.transferAccount.data) { $transferAccountId = $relationships.transferAccount.data.id }

            $roundUp = $null
            if ($attributes.roundUp -and $attributes.roundUp.amount) { $roundUp = [decimal]$attributes.roundUp.amount.value }

            $cardMethod = ""
            $cardSuffix = ""
            if ($attributes.cardPurchaseMethod) {
                $cardMethod = [string]$attributes.cardPurchaseMethod.method
                $cardSuffix = [string]$attributes.cardPurchaseMethod.cardNumberSuffix
            }

            $note = ""
            if ($attributes.note) { $note = [string]$attributes.note.text }

            $report = [ordered]@{
                Id              = $txn.id
                CreatedAt       = $createdAt
                SettledAt       = $settledAt
                Status          = [string]$attributes.status
                Description     = [string]$attributes.description
                Message         = [string]$attributes.message
                RawText         = [string]$attributes.rawText
                Amount          = [decimal]$attributes.amount.value
                CurrencyCode    = [string]$attributes.amount.currencyCode
                ForeignAmount   = $foreignAmount
                ForeignCurrency = $foreignCurrency
                TransactionType = [string]$attributes.transactionType
                Category        = $category
                ParentCategory  = $parentCategory
                Tags            = $tags
                AccountId       = $accountId
            }
            if ($ResolveAccountNames) {
                $accountName = ""
                if ($accountNames.ContainsKey($accountId)) { $accountName = $accountNames[$accountId] }
                $report['AccountName'] = $accountName
            }
            $report['TransferAccountId'] = $transferAccountId
            $report['RoundUp'] = $roundUp
            $report['CardMethod'] = $cardMethod
            $report['CardSuffix'] = $cardSuffix
            $report['Note'] = $note
            $report['DeepLinkURL'] = [string]$attributes.deepLinkURL

            [PSCustomObject]$report
        }
    }
}