container: hledger-flow: btcpayserver: support "Legacy Invoice Export" plugin, update "Wallets" impl

BTCPay Server v2.2.0 has a new "Invoices" export format that is more
complex than the prior "Legacy Invoice Export" format.

To help facilitate the transition to the new format, upstream has
provided a plugin for backwards compatibility. However, this plugin
happens to provide additional columns that must be supported.

Additionaly, the "Wallets" format also has new columns with fee
information, so the impl now supports tx fees (prior, fees required
manual input).

The "Wallets" impl also now defaults to a refund expense account for
outgoing txs and adds tax rules & documentation for rationale.
This commit is contained in:
2025-08-05 09:52:02 -07:00
parent f0d20ffe26
commit 5920d87f86
2 changed files with 88 additions and 32 deletions

View File

@@ -49,13 +49,15 @@ function btcpayserver::print_warning()
#
# TL;DR:
#
# - As of v2.2.0, *MUST* install the "Legacy Invoice Export" plugin. TODO: use v2.2.0 format w/out plugin
#
# - *MUST* export both "Legacy Invoice" report and "Wallets" report for an accurate balance.
# * The "Legacy Invoice" report currently does *not* include any outgoing txs (refunds or transfers).
#
# WARNING:
#
# - All outgoing txs do *not* account for fees (they are lumped in with the total).
# * Until upstream changes this, any fees must be separated manually w/ custom rules.
# - If using a watch-ony wallet, consider accounting entirely here (via btcpayserver) or
# entirely via that other wallet (e.g., electrum). Mixing the two may cause tax accounting issues.
#
# CAUTION:
#
@@ -119,16 +121,19 @@ function btcpayserver::legacy()
printf $16 OFS # InvoiceCurrency
printf $17 OFS # InvoiceDue
printf $18 OFS # InvoicePrice
printf $19 OFS # InvoiceItemCode
printf $19 OFS # InvoiceTaxIncluded
printf $20 OFS # InvoiceTip
printf $21 OFS # InvoiceSubtotal
printf $22 OFS # InvoiceItemCode
# TODO: if description contains comma(s)?
printf $20 OFS # InvoiceItemDesc
printf $23 OFS # InvoiceItemDesc
printf $21 OFS # InvoiceFullStatus
printf $22 OFS # InvoiceStatus
printf $23 OFS # InvoiceExceptionStatus
printf $24 OFS # BuyerEmail
printf $25 OFS # Accounted
printf $24 OFS # InvoiceFullStatus
printf $25 OFS # InvoiceStatus
printf $26 OFS # InvoiceExceptionStatus
printf $27 OFS # BuyerEmail
printf $28 OFS # Accounted
# WARNING: appears to be always IN (see notes regarding "Wallets" report)
printf "IN" OFS # Direction
@@ -176,6 +181,9 @@ function btcpayserver::wallets()
if ($6 !~ /^-/)
next
# Strip sign from amount (using direction instead)
sub(/^-/, "", $6)
# Date (ReceivedDate w/ local timezone added)
sub(/ /, "T", $1) # HACK: makes arg-friendly by removing space
cmd = "date \"+%F %T %z\" --date="$1 | getline date
@@ -197,14 +205,25 @@ function btcpayserver::wallets()
printf $2 OFS # Crypto (CryptoCode)
# BalanceChange (Paid)
printf("%.8f", $6); printf OFS
# NOTE: must provide as subtotal, so subtract fee
printf("%.8f", $6 - $7); printf OFS
# Fee (NetworkFee)
printf("%.8f", $7); printf OFS
printf OFS # (NetworkFee)
printf OFS # (ConversionRate)
printf OFS # (PaidCurrency)
printf OFS # (InvoiceCurrency)
# FeeRate (PaidCurrency)
printf("%.3f", $8); printf OFS
# Rate (XXX) (InvoiceCurrency)
printf("%.3f", $9); printf OFS
printf OFS # (InvoiceDue)
printf OFS # (InvoicePrice)
printf OFS # (InvoiceTaxIncluded)
printf OFS # (InvoiceTip)
printf OFS # (InvoiceSubtotal)
printf OFS # (InvoiceItemCode)
printf OFS # (InvoiceItemDesc)
printf OFS # (InvoiceFullStatus)
@@ -223,7 +242,7 @@ function btcpayserver::wallets()
function btcpayserver::parse()
{
lib_preprocess::test_header "ReceivedDate,StoreId,OrderId,InvoiceId,InvoiceCreatedDate,InvoiceExpirationDate,InvoiceMonitoringDate,PaymentId,Destination,PaymentType,CryptoCode,Paid,NetworkFee,ConversionRate,PaidCurrency,InvoiceCurrency,InvoiceDue,InvoicePrice,InvoiceItemCode,InvoiceItemDesc,InvoiceFullStatus,InvoiceStatus,InvoiceExceptionStatus,BuyerEmail,Accounted" \
lib_preprocess::test_header "ReceivedDate,StoreId,OrderId,InvoiceId,InvoiceCreatedDate,InvoiceExpirationDate,InvoiceMonitoringDate,PaymentId,Destination,PaymentType,CryptoCode,Paid,NetworkFee,ConversionRate,PaidCurrency,InvoiceCurrency,InvoiceDue,InvoicePrice,InvoiceTaxIncluded,InvoiceTip,InvoiceSubtotal,InvoiceItemCode,InvoiceItemDesc,InvoiceFullStatus,InvoiceStatus,InvoiceExceptionStatus,BuyerEmail,Accounted" \
&& btcpayserver::legacy
lib_preprocess::test_header "Date,InvoiceId,OrderId,Category,PaymentMethodId,Confirmed,Address,PaymentCurrency,PaymentAmount,PaymentMethodFee,LightningAddress,InvoiceCurrency,InvoiceCurrencyAmount,Rate" \
@@ -238,7 +257,9 @@ function btcpayserver::parse()
lib_preprocess::test_header "Date,InvoiceId,State,AppId,Product,Quantity,CurrencyAmount,Currency" \
&& btcpayserver::sales
lib_preprocess::test_header "Date,Crypto,TransactionId,InvoiceId,Confirmed,BalanceChange" \
# TODO: don't hardcode USD (upstream should make the currency in "Rate (XXX)" into a separate column).
#lib_preprocess::test_header "Date,Crypto,TransactionId,InvoiceId,Confirmed,BalanceChange,Fee,FeeRate,Rate (USD)" \
lib_preprocess::test_header "Date,Crypto,TransactionId,InvoiceId,Confirmed,BalanceChange,Fee,FeeRate" \
&& btcpayserver::wallets
}

View File

@@ -16,7 +16,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# TODO: currently, only supports "Legacy Invoice" and "Wallets" format (w/ additional custom columns)
fields ReceivedDate,StoreId,OrderId,InvoiceId,InvoiceCreatedDate,InvoiceExpirationDate,InvoiceMonitoringDate,txid,index,Destination,PaymentType,CryptoCode,Paid,NetworkFee,ConversionRate,PaidCurrency,InvoiceCurrency,InvoiceDue,InvoicePrice,InvoiceItemCode,InvoiceItemDesc,InvoiceFullStatus,InvoiceStatus,InvoiceExceptionStatus,BuyerEmail,Accounted,direction,subaccount
fields ReceivedDate,StoreId,OrderId,InvoiceId,InvoiceCreatedDate,InvoiceExpirationDate,InvoiceMonitoringDate,txid,index,Destination,PaymentType,CryptoCode,Paid,NetworkFee,ConversionRate,PaidCurrency,InvoiceCurrency,InvoiceDue,InvoicePrice,InvoiceTaxIncluded,InvoiceTip,InvoiceSubtotal,InvoiceItemCode,InvoiceItemDesc,InvoiceFullStatus,InvoiceStatus,InvoiceExceptionStatus,BuyerEmail,Accounted,direction,subaccount
# NOTE: BTCPayServer exports to localtime
date-format %Y-%m-%d %H:%M:%S %z
@@ -41,43 +41,78 @@ if %Accounted ^[^a-z]*$
# NOTE: all IN assumed to be INCOME
# Default comment
comment created:%InvoiceCreatedDate, expired:%InvoiceExpirationDate, store_id:%StoreId, order_id:%OrderId, invoice_id:%InvoiceId, type:%PaymentType, to_address:%Destination, txid:%txid, index:%index, status:%InvoiceStatus, direction:%direction, taxed_as:INCOME
comment created:%InvoiceCreatedDate, expired:%InvoiceExpirationDate, store_id:%StoreId, order_id:%OrderId, invoice_id:%InvoiceId, invoice_tax:%InvoiceTaxIncluded, invoice_tip:%InvoiceTip, invoice_subtotal:%InvoiceSubtotal, type:%PaymentType, to_address:%Destination, txid:%txid, index:%index, status:%InvoiceStatus, direction:%direction, taxed_as:INCOME
# Comment w/ item code
if %InvoiceItemCode [a-z0-9]
comment created:%InvoiceCreatedDate, expired:%InvoiceExpirationDate, store_id:%StoreId, order_id:%OrderId, invoice_id:%InvoiceId, item_code:%InvoiceItemCode, type:%PaymentType, to_address:%Destination, txid:%txid, index:%index, status:%InvoiceStatus, direction:%direction, taxed_as:INCOME
comment created:%InvoiceCreatedDate, expired:%InvoiceExpirationDate, store_id:%StoreId, order_id:%OrderId, invoice_id:%InvoiceId, invoice_tax:%InvoiceTaxIncluded, invoice_tip:%InvoiceTip, invoice_subtotal:%InvoiceSubtotal, item_code:%InvoiceItemCode, type:%PaymentType, to_address:%Destination, txid:%txid, index:%index, status:%InvoiceStatus, direction:%direction, taxed_as:INCOME
# Comment w/ buyer email
if %BuyerEmail [a-z0-9]
comment created:%InvoiceCreatedDate, expired:%InvoiceExpirationDate, store_id:%StoreId, order_id:%OrderId, invoice_id:%InvoiceId, email:%BuyerEmail, type:%PaymentType, to_address:%Destination, txid:%txid, index:%index, status:%InvoiceStatus, direction:%direction, taxed_as:INCOME
comment created:%InvoiceCreatedDate, expired:%InvoiceExpirationDate, store_id:%StoreId, order_id:%OrderId, invoice_id:%InvoiceId, invoice_tax:%InvoiceTaxIncluded, invoice_tip:%InvoiceTip, invoice_subtotal:%InvoiceSubtotal, email:%BuyerEmail, type:%PaymentType, to_address:%Destination, txid:%txid, index:%index, status:%InvoiceStatus, direction:%direction, taxed_as:INCOME
# Comment w/ both item code + buyer email
if %InvoiceItemCode [a-z0-9]
& %BuyerEmail [a-z0-9]
comment created:%InvoiceCreatedDate, expired:%InvoiceExpirationDate, store_id:%StoreId, order_id:%OrderId, invoice_id:%InvoiceId, item_code:%InvoiceItemCode, email:%BuyerEmail, type:%PaymentType, to_address:%Destination, txid:%txid, index:%index, status:%InvoiceStatus, direction:%direction, taxed_as:INCOME
# Comment for "Wallets" export
if %direction ^OUT$
comment txid:%txid, confirmed:%Accounted, direction:%direction
comment created:%InvoiceCreatedDate, expired:%InvoiceExpirationDate, store_id:%StoreId, order_id:%OrderId, invoice_id:%InvoiceId, invoice_tax:%InvoiceTaxIncluded, invoice_tip:%InvoiceTip, invoice_subtotal:%InvoiceSubtotal, item_code:%InvoiceItemCode, email:%BuyerEmail, type:%PaymentType, to_address:%Destination, txid:%txid, index:%index, status:%InvoiceStatus, direction:%direction, taxed_as:INCOME
#
# Direction
#
# NOTE/TODO:
#
# InvoiceTaxIncluded, InvoiceTip and InvoiceSubtotal are not included
# as new accounts because they are static across rows and treated more
# like tags (i.e., if an invoice is not paid in full in a single tx,
# then these entries will be repeated across rows).
# Default income
if %direction ^IN$
account2 income:btcpayserver:%subaccount:%CryptoCode
comment2 %ReceivedDate,INCOME,btcpayserver:%subaccount,%CryptoCode,%Paid,%InvoiceCurrency,%PaidCurrency,%InvoiceId
# Default equity transfer
# Default refund
#
# NOTE:
#
# - If the tx is not a refund, use custom rules for account2/comment2
# * If the tx also not an expense, remove comment tag taxed_as:SPEND
#
# - Since refunds may not be considered taxable events, and since the
# "Wallets" export only includes the market price of the tx at the
# time of sending (refunding), use custom rules to replace the
# sending tx's price to the time of receiving (the income tx).
#
# This way one can "negate" the original income event by "spending"
# the original amount at the original rate (the rate at which the
# invoice was paid):
#
# Example in custom rules (replace "MANUAL_ADJUSTMENT_NEEDED"):
#
# if %direction ^OUT$
# comment txid:%txid, confirmed:%Accounted, direction:%direction, taxed_as:SPEND
# comment2 %ReceivedDate,SPEND,btcpayserver:%subaccount:%CryptoCode,%CryptoCode,%Paid,USD,MANUAL_ADJUSTMENT_NEEDED,REFUND
#
# - Use different custom rules if your refund type is not the default
# provided option of 'price at the rate the invoice was paid'.
#
if %direction ^OUT$
account2 equity:btcpayserver:%subaccount:deposit:%CryptoCode
account2 expenses:btcpayserver:%subaccount:refunds:%CryptoCode
amount -%Paid %CryptoCode
comment txid:%txid, confirmed:%Accounted, direction:%direction, taxed_as:SPEND
comment2 %ReceivedDate,SPEND,btcpayserver:%subaccount:%CryptoCode,%CryptoCode,%Paid,USD,,REFUND
# TODO: don't hardcode USD (upstream should make the "Rate (XXX)" currency column a separate column).
# TODO:
#
# WARNING:
#
# - All outgoing txs do *not* account for fees (they are lumped in with the total).
# * Until upstream changes this, any fees must be separated manually w/ custom rules.
if %direction ^OUT$
& %NetworkFee [1-9]
account3 assets:btcpayserver:%subaccount:%CryptoCode
amount3 -%NetworkFee %CryptoCode
account4 expenses:electrum:%subaccount:fees:%CryptoCode
amount4 %NetworkFee %CryptoCode
comment txid:%txid, confirmed:%Accounted, direction:%direction, taxed_as:SPEND
comment3 %ReceivedDate,SPEND,btcpayserver:%subaccount:%CryptoCode,%CryptoCode,%NetworkFee,USD,%PaidCurrency,FEE
# Using comment3 so a comment2 SPEND isn't overwritten (when applicable)
# TODO: don't hardcode USD (upstream should make the "Rate (XXX)" currency column a separate column).
# vim: sw=2 sts=2 si ai et