invoices: multi-crypto payment support
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:
parent
f9c7001e7a
commit
0fbb6ee809
20 changed files with 410 additions and 143 deletions
64
lib/crypto_asset.rb
Normal file
64
lib/crypto_asset.rb
Normal 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
33
lib/crypto_qr.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue