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: # 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. # - *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). # * The "Legacy Invoice" report currently does *not* include any outgoing txs (refunds or transfers).
# #
# WARNING: # WARNING:
# #
# - All outgoing txs do *not* account for fees (they are lumped in with the total). # - If using a watch-ony wallet, consider accounting entirely here (via btcpayserver) or
# * Until upstream changes this, any fees must be separated manually w/ custom rules. # entirely via that other wallet (e.g., electrum). Mixing the two may cause tax accounting issues.
# #
# CAUTION: # CAUTION:
# #
@@ -119,16 +121,19 @@ function btcpayserver::legacy()
printf $16 OFS # InvoiceCurrency printf $16 OFS # InvoiceCurrency
printf $17 OFS # InvoiceDue printf $17 OFS # InvoiceDue
printf $18 OFS # InvoicePrice 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)? # TODO: if description contains comma(s)?
printf $20 OFS # InvoiceItemDesc printf $23 OFS # InvoiceItemDesc
printf $21 OFS # InvoiceFullStatus printf $24 OFS # InvoiceFullStatus
printf $22 OFS # InvoiceStatus printf $25 OFS # InvoiceStatus
printf $23 OFS # InvoiceExceptionStatus printf $26 OFS # InvoiceExceptionStatus
printf $24 OFS # BuyerEmail printf $27 OFS # BuyerEmail
printf $25 OFS # Accounted printf $28 OFS # Accounted
# WARNING: appears to be always IN (see notes regarding "Wallets" report) # WARNING: appears to be always IN (see notes regarding "Wallets" report)
printf "IN" OFS # Direction printf "IN" OFS # Direction
@@ -176,6 +181,9 @@ function btcpayserver::wallets()
if ($6 !~ /^-/) if ($6 !~ /^-/)
next next
# Strip sign from amount (using direction instead)
sub(/^-/, "", $6)
# Date (ReceivedDate w/ local timezone added) # Date (ReceivedDate w/ local timezone added)
sub(/ /, "T", $1) # HACK: makes arg-friendly by removing space sub(/ /, "T", $1) # HACK: makes arg-friendly by removing space
cmd = "date \"+%F %T %z\" --date="$1 | getline date cmd = "date \"+%F %T %z\" --date="$1 | getline date
@@ -197,14 +205,25 @@ function btcpayserver::wallets()
printf $2 OFS # Crypto (CryptoCode) printf $2 OFS # Crypto (CryptoCode)
# BalanceChange (Paid) # 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 # (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 # (InvoiceDue)
printf OFS # (InvoicePrice) printf OFS # (InvoicePrice)
printf OFS # (InvoiceTaxIncluded)
printf OFS # (InvoiceTip)
printf OFS # (InvoiceSubtotal)
printf OFS # (InvoiceItemCode) printf OFS # (InvoiceItemCode)
printf OFS # (InvoiceItemDesc) printf OFS # (InvoiceItemDesc)
printf OFS # (InvoiceFullStatus) printf OFS # (InvoiceFullStatus)
@@ -223,7 +242,7 @@ function btcpayserver::wallets()
function btcpayserver::parse() 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 && btcpayserver::legacy
lib_preprocess::test_header "Date,InvoiceId,OrderId,Category,PaymentMethodId,Confirmed,Address,PaymentCurrency,PaymentAmount,PaymentMethodFee,LightningAddress,InvoiceCurrency,InvoiceCurrencyAmount,Rate" \ 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" \ lib_preprocess::test_header "Date,InvoiceId,State,AppId,Product,Quantity,CurrencyAmount,Currency" \
&& btcpayserver::sales && 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 && btcpayserver::wallets
} }

View File

@@ -16,7 +16,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # 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) # 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 # NOTE: BTCPayServer exports to localtime
date-format %Y-%m-%d %H:%M:%S %z date-format %Y-%m-%d %H:%M:%S %z
@@ -41,43 +41,78 @@ if %Accounted ^[^a-z]*$
# NOTE: all IN assumed to be INCOME # NOTE: all IN assumed to be INCOME
# Default comment # 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 # Comment w/ item code
if %InvoiceItemCode [a-z0-9] 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 # Comment w/ buyer email
if %BuyerEmail [a-z0-9] 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 # Comment w/ both item code + buyer email
if %InvoiceItemCode [a-z0-9] if %InvoiceItemCode [a-z0-9]
& %BuyerEmail [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 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
# Comment for "Wallets" export
if %direction ^OUT$
comment txid:%txid, confirmed:%Accounted, direction:%direction
# #
# Direction # 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 # Default income
if %direction ^IN$ if %direction ^IN$
account2 income:btcpayserver:%subaccount:%CryptoCode account2 income:btcpayserver:%subaccount:%CryptoCode
comment2 %ReceivedDate,INCOME,btcpayserver:%subaccount,%CryptoCode,%Paid,%InvoiceCurrency,%PaidCurrency,%InvoiceId 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$ 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: if %direction ^OUT$
# & %NetworkFee [1-9]
# WARNING: account3 assets:btcpayserver:%subaccount:%CryptoCode
# amount3 -%NetworkFee %CryptoCode
# - All outgoing txs do *not* account for fees (they are lumped in with the total). account4 expenses:electrum:%subaccount:fees:%CryptoCode
# * Until upstream changes this, any fees must be separated manually w/ custom rules. 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 # vim: sw=2 sts=2 si ai et