invoices: multi-crypto payment support
All checks were successful
Build and Deploy to Production / test (push) Successful in 18s
Build and Deploy to Production / build (push) Successful in 49s
Build and Deploy to Production / deploy (push) Successful in 31s

One asset per invoice from a registry (lib/crypto_asset.rb): BTC, LTC,
ETH, XMR, SOL, ALGO, USDT/USDC on ERC-20/TRC-20/BEP-20/Solana/Algorand.
Migration 003 generalizes the ltc_* columns to crypto_* + crypto_coin
(existing LTC invoices backfilled). QR payload adapts per asset: BIP21
amount URIs where supported, bare address for tokens with a network
hint on the PDF. Default addresses come from the CRYPTO_ADDRESSES
secret (JSON code=>address); LTC_ADDRESS still works as legacy.
This commit is contained in:
Sergei Poljanski 2026-07-03 02:01:57 +04:00
commit 0fbb6ee809
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
20 changed files with 410 additions and 143 deletions

64
lib/crypto_asset.rb Normal file
View file

@ -0,0 +1,64 @@
require 'json'
# Registry of crypto payment assets — one asset (coin, or token+chain for
# stablecoins) per invoice. Adding an asset is one entry here plus, optionally,
# a default payout address in the CRYPTO_ADDRESSES env (JSON, code => address).
#
# QR encoding is driven by scheme/amount_param:
# scheme + amount_param => scheme:<addr>?<param>=<amt> (wallet prefills amount)
# scheme only => scheme:<addr> (amount shown as text)
# neither => bare address (tokens/chains with no
# standard payment URI)
# coingecko is the id for the simple/price endpoint; chain variants of a
# stablecoin share the token's id.
module CryptoAsset
ASSETS = {
'BTC' => { name: 'Bitcoin', coingecko: 'bitcoin', scheme: 'bitcoin', amount_param: 'amount' },
'LTC' => { name: 'Litecoin', coingecko: 'litecoin', scheme: 'litecoin', amount_param: 'amount' },
'ETH' => { name: 'Ethereum', coingecko: 'ethereum', scheme: 'ethereum', amount_param: nil },
'XMR' => { name: 'Monero', coingecko: 'monero', scheme: 'monero', amount_param: 'tx_amount' },
'SOL' => { name: 'Solana', coingecko: 'solana', scheme: 'solana', amount_param: 'amount' },
'ALGO' => { name: 'Algorand', coingecko: 'algorand', scheme: nil, amount_param: nil },
'USDT-ERC20' => { name: 'Tether (Ethereum)', coingecko: 'tether', scheme: nil, amount_param: nil },
'USDT-TRC20' => { name: 'Tether (Tron)', coingecko: 'tether', scheme: nil, amount_param: nil },
'USDT-BEP20' => { name: 'Tether (BNB Chain)', coingecko: 'tether', scheme: nil, amount_param: nil },
'USDT-SOL' => { name: 'Tether (Solana)', coingecko: 'tether', scheme: nil, amount_param: nil },
'USDC-ERC20' => { name: 'USD Coin (Ethereum)', coingecko: 'usd-coin', scheme: nil, amount_param: nil },
'USDC-BEP20' => { name: 'USD Coin (BNB Chain)', coingecko: 'usd-coin', scheme: nil, amount_param: nil },
'USDC-SOL' => { name: 'USD Coin (Solana)', coingecko: 'usd-coin', scheme: nil, amount_param: nil },
'USDC-ALGO' => { name: 'USD Coin (Algorand)', coingecko: 'usd-coin', scheme: nil, amount_param: nil }
}.freeze
CODES = ASSETS.keys.freeze
module_function
def [](code)
ASSETS[code.to_s.upcase]
end
def valid?(code)
ASSETS.key?(code.to_s.upcase)
end
def name(code)
asset = self[code]
asset ? asset[:name] : code.to_s
end
# Default payout addresses for the new-invoice form. CRYPTO_ADDRESSES is a
# JSON object (code => address); the legacy LTC_ADDRESS env still fills LTC.
# Unknown codes are dropped so a typo in the env can't invent an asset.
def default_addresses
map = begin
JSON.parse(ENV['CRYPTO_ADDRESSES'].to_s)
rescue JSON::ParserError
{}
end
map = {} unless map.is_a?(Hash)
map = map.transform_keys { |k| k.to_s.upcase }
ltc = ENV['LTC_ADDRESS'].to_s
map['LTC'] = ltc unless ltc.empty? || map.key?('LTC')
map.slice(*CODES)
end
end

33
lib/crypto_qr.rb Normal file
View file

@ -0,0 +1,33 @@
require 'rqrcode'
require_relative 'crypto_asset'
# Renders a crypto payment QR as PNG bytes for embedding in the PDF.
# The encoded payload depends on the asset (see CryptoAsset): a payment URI
# with amount, a bare scheme URI, or just the address.
module CryptoQr
module_function
# `amount` is a BigDecimal or nil (address-only QR).
def uri(coin, address, amount)
asset = CryptoAsset[coin] or return address
return address unless asset[:scheme]
base = "#{asset[:scheme]}:#{address}"
return base if amount.nil? || asset[:amount_param].nil?
# Trim trailing zeros so the wallet shows a clean amount; keep up to 8 dp.
amt = amount.round(8).to_s('F').sub(/\.?0+$/, '')
"#{base}?#{asset[:amount_param]}=#{amt}"
end
# Returns PNG bytes (binary string), sized for a crisp ~120pt PDF placement.
def png(coin, address, amount, size: 480)
qr = RQRCode::QRCode.new(uri(coin, address, amount), level: :m)
qr.as_png(
bit_depth: 1,
border_modules: 2,
color: 'black',
fill: 'white',
module_px_size: 6,
resize_exactly_to: size
).to_s
end
end

View file

@ -2,13 +2,13 @@ require 'net/http'
require 'json'
require 'uri'
require 'bigdecimal'
require_relative 'crypto_asset'
# Fetches the current Litecoin price from CoinGecko (free, no API key).
# Fetches the current price of a CryptoAsset from CoinGecko (free, no API key).
#
# CoinGecko's simple/price supports usd and eur directly. GEL is not a
# CoinGecko vs_currency we rely on, so GEL is derived from the invoice's own
# captured gel_rate (USD->GEL): ltc_in_gel = ltc_in_usd * gel_rate.
module LtcRate
# CoinGecko's simple/price supports usd and eur directly. GEL is derived from
# the invoice's own captured gel_rate (USD->GEL): price_gel = price_usd * gel_rate.
module CryptoRate
ENDPOINT = 'https://api.coingecko.com/api/v3/simple/price'.freeze
SUPPORTED = %w[USD EUR].freeze
TIMEOUT = 6 # seconds
@ -17,25 +17,27 @@ module LtcRate
module_function
# Returns a BigDecimal: the price of 1 LTC in `currency`.
# Returns a BigDecimal: the price of 1 unit of `coin` in `currency`.
# `gel_rate` (USD->GEL) is required only when currency == 'GEL'.
def fetch(currency, gel_rate: nil)
def fetch(coin, currency, gel_rate: nil)
asset = CryptoAsset[coin] or raise Error, "Unknown coin: #{coin}"
id = asset[:coingecko]
currency = currency.to_s.upcase
if currency == 'GEL'
raise Error, 'gel_rate required to derive LTC/GEL' if gel_rate.nil?
usd = fetch_simple('usd')
raise Error, 'gel_rate required to derive the GEL price' if gel_rate.nil?
usd = fetch_simple(id, 'usd')
(usd * BigDecimal(gel_rate.to_s)).round(8)
elsif SUPPORTED.include?(currency)
fetch_simple(currency.downcase).round(8)
fetch_simple(id, currency.downcase).round(8)
else
raise Error, "Unsupported currency: #{currency}"
end
end
def fetch_simple(vs)
def fetch_simple(id, vs)
uri = URI(ENDPOINT)
uri.query = URI.encode_www_form(ids: 'litecoin', vs_currencies: vs)
uri.query = URI.encode_www_form(ids: id, vs_currencies: vs)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
@ -45,8 +47,8 @@ module LtcRate
raise Error, "CoinGecko HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
data = JSON.parse(res.body)
price = data.dig('litecoin', vs)
raise Error, "No price for #{vs} in response" if price.nil?
price = data.dig(id, vs)
raise Error, "No price for #{id}/#{vs} in response" if price.nil?
BigDecimal(price.to_s)
rescue JSON::ParserError => e

View file

@ -13,8 +13,8 @@ module Fmt
"#{group(int)}.#{frac}"
end
# LTC amount: up to 8 dp, trailing zeros trimmed, no padding.
def ltc(value)
# Crypto amount: up to 8 dp, trailing zeros trimmed, no padding.
def crypto(value)
BigDecimal(value.to_s).round(8).to_s('F').sub(/(\.\d*?)0+$/, '\1').sub(/\.$/, '')
end

View file

@ -2,6 +2,7 @@ require 'sequel'
require 'securerandom'
require 'json'
require 'bigdecimal'
require_relative 'crypto_asset'
class Invoice < Sequel::Model(:invoices)
CURRENCIES = %w[USD EUR GEL].freeze
@ -46,17 +47,17 @@ class Invoice < Sequel::Model(:invoices)
(total * BigDecimal(gel_rate.to_s)).round(2)
end
def ltc?
!ltc_address.to_s.strip.empty?
def crypto?
!crypto_address.to_s.strip.empty?
end
# The LTC amount to display / encode in the QR. Returns nil when no amount
# is known (address-only invoice).
def ltc_amount_due
return nil unless ltc?
return BigDecimal(ltc_amount.to_s) unless ltc_amount.nil?
return nil if ltc_rate.nil? || BigDecimal(ltc_rate.to_s).zero?
(total / BigDecimal(ltc_rate.to_s)).round(8)
# The crypto amount to display / encode in the QR. Returns nil when no
# amount is known (address-only invoice).
def crypto_amount_due
return nil unless crypto?
return BigDecimal(crypto_amount.to_s) unless crypto_amount.nil?
return nil if crypto_rate.nil? || BigDecimal(crypto_rate.to_s).zero?
(total / BigDecimal(crypto_rate.to_s)).round(8)
end
class << self
@ -89,27 +90,32 @@ class Invoice < Sequel::Model(:invoices)
pdf_key: '',
created_at: Time.now.utc
)
apply_ltc(invoice, params, subtotal)
apply_crypto(invoice, params, subtotal)
invoice.uuid = SecureRandom.uuid
invoice
end
private
# LTC is opt-in: only populated when an address is given. Rate and amount
# are both optional; if amount is blank but rate is present, derive it.
def apply_ltc(invoice, params, subtotal)
address = params[:ltc_address].to_s.strip
# Crypto is opt-in: only populated when an address is given. Rate and
# amount are both optional; if amount is blank but rate is present,
# derive it.
def apply_crypto(invoice, params, subtotal)
address = params[:crypto_address].to_s.strip
return if address.empty?
invoice.ltc_address = address
coin = params[:crypto_coin].to_s.upcase
CryptoAsset.valid?(coin) or raise ArgumentError, "Unknown coin: #{coin}"
rate = decimal_or_nil(params[:ltc_rate])
invoice.ltc_rate = rate
invoice.crypto_coin = coin
invoice.crypto_address = address
amount = decimal_or_nil(params[:ltc_amount])
rate = decimal_or_nil(params[:crypto_rate])
invoice.crypto_rate = rate
amount = decimal_or_nil(params[:crypto_amount])
amount ||= (subtotal / rate).round(8) if rate && !rate.zero?
invoice.ltc_amount = amount
invoice.crypto_amount = amount
end
def decimal_or_nil(raw)

View file

@ -3,6 +3,8 @@ require 'prawn/table'
require 'bigdecimal'
require 'stringio'
require_relative 'fmt'
require_relative 'crypto_asset'
require_relative 'crypto_qr'
class InvoicePdf
FONTS_DIR = File.join($root, 'public', 'fonts')
@ -246,7 +248,7 @@ class InvoicePdf
pdf.text "Beneficiary: #{ISSUER[:name_latin]}"
end
draw_ltc(pdf) if @invoice.respond_to?(:ltc?) && @invoice.ltc?
draw_crypto(pdf) if @invoice.respond_to?(:crypto?) && @invoice.crypto?
unless @invoice.notes.to_s.strip.empty?
pdf.move_down 14
@ -258,20 +260,22 @@ class InvoicePdf
end
end
def draw_ltc(pdf)
amount = @invoice.ltc_amount_due
def draw_crypto(pdf)
coin = @invoice.crypto_coin
asset = CryptoAsset[coin] || {}
amount = @invoice.crypto_amount_due
qr_size = 78
pdf.move_down 12
pdf.fill_color COLOR_MUTED
pdf.font_size(8) { pdf.text 'PAY IN LITECOIN (LTC)', character_spacing: 1.5 }
pdf.font_size(8) { pdf.text "PAY IN #{CryptoAsset.name(coin).upcase} (#{coin})", character_spacing: 1.5 }
pdf.fill_color COLOR_TEXT
pdf.move_down 4
# Fixed-height row: QR on the right (drawn with float so it doesn't advance
# the cursor), text column on the left. The outer bounding_box of height
# qr_size leaves the cursor exactly below the row when it ends.
qr_bytes = LtcQr.png(@invoice.ltc_address, amount)
qr_bytes = CryptoQr.png(coin, @invoice.crypto_address, amount)
pdf.bounding_box([0, pdf.cursor], width: pdf.bounds.width, height: qr_size) do
pdf.float do
pdf.bounding_box([pdf.bounds.right - qr_size, pdf.bounds.top], width: qr_size, height: qr_size) do
@ -282,19 +286,24 @@ class InvoicePdf
pdf.bounding_box([0, pdf.bounds.top], width: pdf.bounds.width - qr_size - 16) do
pdf.font_size(9) do
if amount
pdf.text "Amount: #{fmt_ltc(amount)} LTC", style: :bold
if @invoice.ltc_rate
pdf.text "Amount: #{fmt_crypto(amount)} #{coin}", style: :bold
if @invoice.crypto_rate
pdf.fill_color COLOR_MUTED
pdf.text "(rate 1 LTC = #{fmt_rate(@invoice.ltc_rate)} #{@invoice.currency} at issue)", size: 8
pdf.text "(rate 1 #{coin} = #{fmt_rate(@invoice.crypto_rate)} #{@invoice.currency} at issue)", size: 8
pdf.fill_color COLOR_TEXT
end
pdf.move_down 3
end
pdf.text 'Address:'
pdf.font_size(8) { pdf.text @invoice.ltc_address }
pdf.font_size(8) { pdf.text @invoice.crypto_address }
pdf.move_down 3
pdf.fill_color COLOR_MUTED
pdf.font_size(7) { pdf.text 'Scan the QR with any Litecoin wallet to prefill the payment.' }
hint = if asset[:amount_param]
"Scan the QR with any #{CryptoAsset.name(coin)} wallet to prefill the payment."
else
"QR encodes the address. Send #{coin} only — verify the network before sending."
end
pdf.font_size(7) { pdf.text hint }
pdf.fill_color COLOR_TEXT
end
end
@ -333,8 +342,8 @@ class InvoicePdf
BigDecimal(value.to_s).round(2).to_s('F').then { |s| with_thousands(s) }
end
def fmt_ltc(value)
Fmt.ltc(value)
def fmt_crypto(value)
Fmt.crypto(value)
end
def fmt_rate(value, min_dp: 4)

View file

@ -1,30 +0,0 @@
require 'rqrcode'
# Renders a Litecoin payment QR as PNG bytes for embedding in the PDF.
# Encodes a BIP21-style URI: litecoin:<address>?amount=<ltc>
module LtcQr
module_function
# `amount` is a BigDecimal LTC amount or nil (address-only QR).
def uri(address, amount)
base = "litecoin:#{address}"
return base if amount.nil?
# Trim trailing zeros so the wallet shows a clean amount; keep up to 8 dp.
amt = amount.round(8).to_s('F').sub(/\.?0+$/, '')
"#{base}?amount=#{amt}"
end
# Returns PNG bytes (binary string), sized for a crisp ~120pt PDF placement.
def png(address, amount, size: 480)
data = uri(address, amount)
qr = RQRCode::QRCode.new(data, level: :m)
qr.as_png(
bit_depth: 1,
border_modules: 2,
color: 'black',
fill: 'white',
module_px_size: 6,
resize_exactly_to: size
).to_s
end
end