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

View file

@ -11,6 +11,9 @@ SMTP_PASSWORD=change-me
MAIL_FROM="IE Sergei Poljanski Contact Form <me@asxp.io>"
MAIL_TO=ie@asxp.io
# Optional: default Litecoin payout address prefilled into the new-invoice form.
# Production sets this from the LTC_ADDRESS Forgejo secret; leave blank locally.
# Optional: default crypto payout addresses prefilled into the new-invoice
# form. JSON object, CryptoAsset code => address (codes in lib/crypto_asset.rb).
# Production sets this from the CRYPTO_ADDRESSES Forgejo secret.
# CRYPTO_ADDRESSES={"BTC":"bc1...","USDT-TRC20":"T..."}
# Legacy: LTC_ADDRESS still fills the LTC default when set.
# LTC_ADDRESS=ltc1...

View file

@ -109,11 +109,12 @@ jobs:
ASXPIO_S3_ACCESS_KEY: ${{ secrets.ASXPIO_S3_ACCESS_KEY }}
ASXPIO_S3_SECRET_KEY: ${{ secrets.ASXPIO_S3_SECRET_KEY }}
LTC_ADDRESS: ${{ secrets.LTC_ADDRESS }}
CRYPTO_ADDRESSES: ${{ secrets.CRYPTO_ADDRESSES }}
with:
host: ${{ secrets.DEPLOY_IP }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
envs: SESSION_SECRET,SMTP_PASSWORD,ADMIN_USER,ADMIN_PASSWORD,ASXPIO_DB_PASSWORD,ASXPIO_S3_ACCESS_KEY,ASXPIO_S3_SECRET_KEY,LTC_ADDRESS
envs: SESSION_SECRET,SMTP_PASSWORD,ADMIN_USER,ADMIN_PASSWORD,ASXPIO_DB_PASSWORD,ASXPIO_S3_ACCESS_KEY,ASXPIO_S3_SECRET_KEY,LTC_ADDRESS,CRYPTO_ADDRESSES
script_stop: true
script: |
umask 077
@ -137,6 +138,7 @@ jobs:
S3_ACCESS_KEY=${ASXPIO_S3_ACCESS_KEY}
S3_SECRET_KEY=${ASXPIO_S3_SECRET_KEY}
LTC_ADDRESS=${LTC_ADDRESS}
CRYPTO_ADDRESSES=${CRYPTO_ADDRESSES}
EOF
chmod 600 /opt/asxpio/.env

View file

@ -89,12 +89,12 @@ Public:
### Storage model
- `invoices` table (`db/migrations/001_invoices.rb`): UUID PK, unique `number` (format `INV-{YYYY}-{NNNN}`, allocated by scanning the current year's max), JSONB `items`, captured-at-issue `gel_rate`, `paid_at` nullable, `pdf_key` for the MinIO object path (`invoices/<number>-<uuid>.pdf`).
- `db/migrations/002_invoice_ltc.rb` adds optional Litecoin payment columns: `ltc_address` (captured at issue), `ltc_rate` (LTC price in the invoice currency, snapshotted), `ltc_amount` (LTC due — derived `total / ltc_rate` unless hand-overridden). All nullable; LTC is opt-in per invoice (blank address ⇒ no LTC block/QR).
- `Invoice` (Sequel model) — number allocation, line-item normalization, `total` / `total_gel` helpers, plus `ltc?` and `ltc_amount_due`. **Always assign `uuid` as an attribute after `Invoice.new(...)`** — passing it to `new` raises `MassAssignmentRestriction` because Sequel guards primary keys.
- `db/migrations/002_invoice_ltc.rb` added Litecoin-only columns; `003_invoice_crypto.rb` generalized them: `crypto_coin` (a `CryptoAsset` code, e.g. `BTC`, `USDT-TRC20`), `crypto_address` (captured at issue), `crypto_rate` (price in the invoice currency, snapshotted), `crypto_amount` (due — derived `total / crypto_rate` unless hand-overridden). All nullable; crypto is opt-in per invoice (blank address ⇒ no crypto block/QR). Pre-migration LTC invoices were backfilled with `crypto_coin = 'LTC'`.
- `Invoice` (Sequel model) — number allocation, line-item normalization, `total` / `total_gel` helpers, plus `crypto?` and `crypto_amount_due`. **Always assign `uuid` as an attribute after `Invoice.new(...)`** — passing it to `new` raises `MassAssignmentRestriction` because Sequel guards primary keys.
### Litecoin payment
### Crypto payment
Opt-in per invoice. The new-invoice form has an LTC section (address prefilled from the `LTC_ADDRESS` env / Forgejo secret, editable), a rate field with a "Fetch live" button, and an editable amount that overrides the derived value. `GET /admin/ltc-rate?currency=&gel_rate=` returns the live LTC price as JSON via `lib/ltc_rate.rb` (CoinGecko, no API key; USD/EUR direct, GEL derived from the form's `gel_rate`). `lib/ltc_qr.rb` renders a `litecoin:<addr>?amount=<ltc>` BIP21 QR (rqrcode + chunky_png) embedded in the PDF.
Opt-in per invoice, one asset per invoice. `lib/crypto_asset.rb` is the registry (BTC, LTC, ETH, XMR, SOL, ALGO, USDT/USDC across ERC-20/TRC-20/BEP-20/Solana/Algorand) — adding an asset is one entry there. The new-invoice form has a coin select plus address (prefilled per coin from the `CRYPTO_ADDRESSES` env — JSON `code => address`; legacy `LTC_ADDRESS` still fills LTC), a rate field with a "Fetch live" button, and an editable amount overriding the derived value. `GET /admin/crypto-rate?coin=&currency=&gel_rate=` returns the live price via `lib/crypto_rate.rb` (CoinGecko, no API key; USD/EUR direct, GEL derived from the form's `gel_rate`; chain variants of a stablecoin share the token's CoinGecko id). `lib/crypto_qr.rb` renders the QR for the PDF: payment URI with amount where a scheme supports it (BTC/LTC BIP21, XMR `tx_amount`, SOL Solana Pay), scheme-only for ETH, bare address for tokens/ALGO (the PDF prints a "verify the network" hint for those).
### PDF rendering
@ -156,7 +156,8 @@ The hedgehog is the user's pre-IE personal mark; once the IE has its own logo, t
| `ASXPIO_DB_PASSWORD` | Password for the `asxpio` Postgres role | Update on **both** repos to the same value. Push storage first (re-provisions role), then asxpio. |
| `ASXPIO_S3_ACCESS_KEY` | MinIO service account key, scoped to `asxpio-invoices` | Same dual-repo update. Push storage first. |
| `ASXPIO_S3_SECRET_KEY` | Matching MinIO secret | Same. |
| `LTC_ADDRESS` | Default Litecoin payout address prefilled into the new-invoice form | Not confidential (printed on every invoice + QR), but kept as a secret so rotation is a one-place change. Update secret + push. Existing invoices keep their captured address. |
| `CRYPTO_ADDRESSES` | JSON object (`CryptoAsset` code => payout address) prefilled per coin into the new-invoice form | Not confidential (printed on invoices + QR), but kept as a secret so rotation is a one-place change. Update secret + push. Existing invoices keep their captured address. |
| `LTC_ADDRESS` | Legacy: fills the LTC default when `CRYPTO_ADDRESSES` has no LTC entry | Same as above. Can be folded into `CRYPTO_ADDRESSES` and removed. |
| `DEPLOY_IP/USER/SSH_KEY` | SSH to prod from CI | Standard SSH key rotation. |
| `FORGEJO_REGISTRY/USER/TOKEN` | Kaniko registry auth | Forgejo token rotation. |

View file

@ -29,8 +29,9 @@ if ENV['DATABASE_URL']
DB.connect!
DB.migrate!
require_relative 'lib/invoice'
require_relative 'lib/ltc_rate'
require_relative 'lib/ltc_qr'
require_relative 'lib/crypto_asset'
require_relative 'lib/crypto_rate'
require_relative 'lib/crypto_qr'
require_relative 'lib/gel_rate'
require_relative 'lib/invoice_pdf'
else
@ -78,8 +79,8 @@ class AsxpioWeb < Sinatra::Base
Fmt.rate(value, min_dp: min_dp)
end
def fmt_ltc(value)
Fmt.ltc(value)
def fmt_crypto(value)
Fmt.crypto(value)
end
end
@ -182,16 +183,18 @@ class AsxpioWeb < Sinatra::Base
erb :'admin/invoices/index'
end
# Live LTC price for the new-invoice form's "Fetch live" button.
# GEL is derived from the gel_rate the operator typed into the form.
get '/admin/ltc-rate' do
# Live price of the selected crypto asset for the new-invoice form's
# "Fetch live" button. GEL is derived from the gel_rate the operator typed
# into the form.
get '/admin/crypto-rate' do
content_type :json
coin = params[:coin].to_s.upcase
currency = params[:currency].to_s.upcase
gel_rate = params[:gel_rate].to_s.strip
begin
rate = LtcRate.fetch(currency, gel_rate: gel_rate.empty? ? nil : gel_rate)
{ rate: rate.round(8).to_s('F'), currency: currency }.to_json
rescue LtcRate::Error => e
rate = CryptoRate.fetch(coin, currency, gel_rate: gel_rate.empty? ? nil : gel_rate)
{ rate: rate.round(8).to_s('F'), coin: coin, currency: currency }.to_json
rescue CryptoRate::Error => e
status 502
{ error: e.message }.to_json
end
@ -249,7 +252,8 @@ class AsxpioWeb < Sinatra::Base
issued_on: Date.today.strftime('%Y-%m-%d'),
due_on: (Date.today + term).strftime('%Y-%m-%d'),
notes: src.notes,
ltc_address: src.ltc_address,
crypto_coin: src.crypto_coin,
crypto_address: src.crypto_address,
items: src.items_array
}
erb :'admin/invoices/new'
@ -265,9 +269,10 @@ class AsxpioWeb < Sinatra::Base
issued_on: params[:issued_on].to_s.strip,
due_on: params[:due_on].to_s.strip,
notes: params[:notes].to_s.strip,
ltc_address: params[:ltc_address].to_s.strip,
ltc_rate: params[:ltc_rate].to_s.strip,
ltc_amount: params[:ltc_amount].to_s.strip,
crypto_coin: params[:crypto_coin].to_s.upcase,
crypto_address: params[:crypto_address].to_s.strip,
crypto_rate: params[:crypto_rate].to_s.strip,
crypto_amount: params[:crypto_amount].to_s.strip,
items: (params[:items] || {}).values
}
@form_errors = validate_invoice_params(@form_values)
@ -375,10 +380,12 @@ class AsxpioWeb < Sinatra::Base
end
v[:items] = items
# LTC is optional; validate only when an address is present.
unless v[:ltc_address].to_s.strip.empty?
errors[:ltc_address] = 'LTC address looks invalid' unless v[:ltc_address] =~ /\A(ltc1|[LM3])[a-zA-HJ-NP-Z0-9]{20,90}\z/
%i[ltc_rate ltc_amount].each do |k|
# Crypto is optional; validate only when an address is present.
unless v[:crypto_address].to_s.strip.empty?
errors[:crypto_coin] = 'Unknown coin' unless CryptoAsset.valid?(v[:crypto_coin])
# Loose shape check: every supported chain's addresses are alphanumeric.
errors[:crypto_address] = 'Address looks invalid' unless v[:crypto_address] =~ /\A[a-zA-Z0-9]{10,128}\z/
%i[crypto_rate crypto_amount].each do |k|
next if v[k].to_s.strip.empty?
begin
raise ArgumentError if BigDecimal(v[k]) <= 0

View file

@ -0,0 +1,22 @@
Sequel.migration do
up do
alter_table(:invoices) do
# Generalize the Litecoin-only columns: one crypto asset per invoice,
# identified by crypto_coin (a CryptoAsset code, e.g. 'BTC', 'USDT-TRC20').
rename_column :ltc_address, :crypto_address
rename_column :ltc_rate, :crypto_rate
rename_column :ltc_amount, :crypto_amount
add_column :crypto_coin, String
end
run "UPDATE invoices SET crypto_coin = 'LTC' WHERE crypto_address IS NOT NULL"
end
down do
alter_table(:invoices) do
drop_column :crypto_coin
rename_column :crypto_address, :ltc_address
rename_column :crypto_rate, :ltc_rate
rename_column :crypto_amount, :ltc_amount
end
end
end

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

View file

@ -36,15 +36,40 @@
reindex();
});
// --- Litecoin: live rate fetch + auto amount ------------------------
const rateInput = document.getElementById('ltc_rate');
const amountInput = document.getElementById('ltc_amount');
const fetchBtn = document.getElementById('fetch-ltc-rate');
const rateStatus = document.getElementById('ltc-rate-status');
const rateCcy = document.getElementById('ltc-rate-ccy');
// --- Crypto: coin select, live rate fetch + auto amount -------------
const coinSel = document.getElementById('crypto_coin');
const addressInput = document.getElementById('crypto_address');
const rateInput = document.getElementById('crypto_rate');
const amountInput = document.getElementById('crypto_amount');
const fetchBtn = document.getElementById('fetch-crypto-rate');
const rateStatus = document.getElementById('crypto-rate-status');
const rateCcy = document.getElementById('crypto-rate-ccy');
const rateCoin = document.getElementById('crypto-rate-coin');
const currencySel = document.getElementById('currency');
const gelRateInput = document.getElementById('gel_rate');
// Default payout addresses per coin, rendered server-side from env.
let cryptoDefaults = {};
const defaultsEl = document.getElementById('crypto-defaults');
if (defaultsEl) {
try { cryptoDefaults = JSON.parse(defaultsEl.textContent); } catch (e) { /* leave empty */ }
}
// On coin change: swap in the new coin's default address, but never
// clobber a hand-entered one (only replace blank or known defaults).
if (coinSel && addressInput) {
coinSel.addEventListener('change', function () {
const val = addressInput.value.trim();
const isDefault = val === '' || Object.values(cryptoDefaults).indexOf(val) !== -1;
if (isDefault) addressInput.value = cryptoDefaults[coinSel.value] || '';
rateInput.value = '';
if (amountInput) amountInput.value = '';
amountTouched = false;
rateStatus.textContent = '';
syncCcyLabel();
});
}
function invoiceTotal() {
let total = 0;
tbody.querySelectorAll('tr.item-row').forEach((row) => {
@ -74,6 +99,7 @@
const ccy = currencySel ? currencySel.value : 'USD';
if (rateCcy) rateCcy.textContent = ccy;
if (gelRateCcy) gelRateCcy.textContent = ccy;
if (rateCoin && coinSel) rateCoin.textContent = coinSel.value;
}
syncCcyLabel();
if (currencySel) currencySel.addEventListener('change', syncCcyLabel);
@ -84,10 +110,11 @@
if (fetchBtn) {
fetchBtn.addEventListener('click', function () {
const ccy = currencySel ? currencySel.value : 'USD';
const params = new URLSearchParams({ currency: ccy });
const coin = coinSel ? coinSel.value : 'LTC';
const params = new URLSearchParams({ coin: coin, currency: ccy });
if (gelRateInput && gelRateInput.value) params.set('gel_rate', gelRateInput.value);
rateStatus.textContent = ' fetching…';
fetch('/admin/ltc-rate?' + params.toString(), { headers: { Accept: 'application/json' } })
fetch('/admin/crypto-rate?' + params.toString(), { headers: { Accept: 'application/json' } })
.then((r) => r.json().then((j) => ({ ok: r.ok, j })))
.then(({ ok, j }) => {
if (!ok) { rateStatus.textContent = ' ' + (j.error || 'failed'); return; }

View file

@ -113,7 +113,7 @@ class AppTest < Minitest::Test
client_name: 'ACME', client_email: 'billing@example.com', client_address: '',
currency: 'EUR', gel_rate: '3.05',
issued_on: Date.today.to_s, due_on: (Date.today + 14).to_s, notes: '',
ltc_address: '', ltc_rate: '', ltc_amount: '',
crypto_coin: 'LTC', crypto_address: '', crypto_rate: '', crypto_amount: '',
items: { '0' => { 'description' => 'Engineering — test', 'qty' => '2', 'unit_price' => '100.50' } } }
end
@ -131,6 +131,22 @@ class AppTest < Minitest::Test
assert_match %r{/admin/invoices/#{invoice.uuid}}, last_response.location
end
def test_create_invoice_with_crypto
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
token = csrf_token_from('/admin/invoices/new', admin_env)
params = invoice_form_params(token).merge(
crypto_coin: 'USDT-TRC20', crypto_address: 'TXYZexampleexampleexampleexample12',
crypto_rate: '1.00', crypto_amount: ''
)
post '/admin/invoices', params, admin_env
assert_equal 302, last_response.status, last_response.body
invoice = Invoice.first
assert_equal 'USDT-TRC20', invoice.crypto_coin
assert_equal BigDecimal('201'), invoice.crypto_amount_due
end
def test_create_invoice_validation_failure_rerenders_form
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
token = csrf_token_from('/admin/invoices/new', admin_env)

71
test/crypto_test.rb Normal file
View file

@ -0,0 +1,71 @@
require_relative 'test_helper'
require_relative '../lib/crypto_asset'
require_relative '../lib/crypto_qr'
class CryptoAssetTest < Minitest::Test
def test_registry_entries_are_complete
CryptoAsset::ASSETS.each do |code, asset|
assert asset[:name], "#{code} missing name"
assert asset[:coingecko], "#{code} missing coingecko id"
end
end
def test_valid_is_case_insensitive
assert CryptoAsset.valid?('btc')
assert CryptoAsset.valid?('usdt-trc20')
refute CryptoAsset.valid?('DOGE')
end
def test_default_addresses_from_json_env
ENV['CRYPTO_ADDRESSES'] = '{"BTC":"bc1qtest","usdt-trc20":"Ttest","DOGE":"ignored"}'
map = CryptoAsset.default_addresses
assert_equal 'bc1qtest', map['BTC']
assert_equal 'Ttest', map['USDT-TRC20']
refute map.key?('DOGE')
ensure
ENV.delete('CRYPTO_ADDRESSES')
end
def test_legacy_ltc_address_env_still_fills_ltc
ENV['LTC_ADDRESS'] = 'ltc1legacy'
assert_equal 'ltc1legacy', CryptoAsset.default_addresses['LTC']
ensure
ENV.delete('LTC_ADDRESS')
end
def test_default_addresses_survives_bad_json
ENV['CRYPTO_ADDRESSES'] = 'not json'
assert_equal({}, CryptoAsset.default_addresses)
ensure
ENV.delete('CRYPTO_ADDRESSES')
end
end
class CryptoQrTest < Minitest::Test
def test_bip21_uri_with_amount
assert_equal 'bitcoin:bc1qaddr?amount=0.005',
CryptoQr.uri('BTC', 'bc1qaddr', BigDecimal('0.00500000'))
assert_equal 'litecoin:ltc1qaddr?amount=1.25',
CryptoQr.uri('LTC', 'ltc1qaddr', BigDecimal('1.25'))
end
def test_monero_uses_tx_amount
assert_equal 'monero:4Aaddr?tx_amount=2.5',
CryptoQr.uri('XMR', '4Aaddr', BigDecimal('2.5'))
end
def test_eth_scheme_without_amount
assert_equal 'ethereum:0xAddr', CryptoQr.uri('ETH', '0xAddr', BigDecimal('1.5'))
end
def test_tokens_encode_bare_address
assert_equal 'TAddr', CryptoQr.uri('USDT-TRC20', 'TAddr', BigDecimal('100'))
assert_equal 'AAddr', CryptoQr.uri('USDC-ALGO', 'AAddr', nil)
end
def test_png_renders
png = CryptoQr.png('BTC', 'bc1qexampleexampleexample', BigDecimal('0.01'))
assert png.start_with?("\x89PNG".b)
end
end

View file

@ -14,9 +14,9 @@ class FmtTest < Minitest::Test
assert_equal '1,050.2500', Fmt.rate('1050.25')
end
def test_ltc_trims_trailing_zeros
assert_equal '1.25', Fmt.ltc('1.250000')
assert_equal '2', Fmt.ltc('2.0')
assert_equal '0.00123456', Fmt.ltc('0.00123456')
def test_crypto_trims_trailing_zeros
assert_equal '1.25', Fmt.crypto('1.250000')
assert_equal '2', Fmt.crypto('2.0')
assert_equal '0.00123456', Fmt.crypto('0.00123456')
end
end

View file

@ -55,25 +55,38 @@ class InvoiceTest < Minitest::Test
assert_equal BigDecimal('201'), saved.total
assert_equal BigDecimal('613.05'), saved.total_gel
assert_equal 'pending', saved.status
refute saved.ltc?
refute saved.crypto?
end
def test_build_derives_ltc_amount_from_rate
def test_build_derives_crypto_amount_from_rate
inv = Invoice.build(
client_name: 'ACME', client_email: 'billing@example.com',
currency: 'EUR', gel_rate: '3.05',
items: [{ 'description' => 'work', 'qty' => '1', 'unit_price' => '100' }],
ltc_address: 'ltc1qexampleexampleexampleexample', ltc_rate: '80', ltc_amount: ''
crypto_coin: 'ltc', crypto_address: 'ltc1qexampleexampleexampleexample',
crypto_rate: '80', crypto_amount: ''
)
assert inv.ltc?
assert_equal BigDecimal('1.25'), inv.ltc_amount_due
assert inv.crypto?
assert_equal 'LTC', inv.crypto_coin
assert_equal BigDecimal('1.25'), inv.crypto_amount_due
end
def test_build_rejects_unknown_coin
assert_raises(ArgumentError) do
Invoice.build(
client_name: 'ACME', client_email: 'billing@example.com',
currency: 'EUR', gel_rate: '3.05',
items: [{ 'description' => 'work', 'qty' => '1', 'unit_price' => '100' }],
crypto_coin: 'DOGE', crypto_address: 'D6examplexampleexampleexample'
)
end
end
end
class InvoiceValidationTest < Minitest::Test
BASE = {
client_name: 'ACME', client_email: 'billing@example.com', currency: 'EUR',
gel_rate: '3.05', ltc_address: '', ltc_rate: '', ltc_amount: '',
gel_rate: '3.05', crypto_coin: 'LTC', crypto_address: '', crypto_rate: '', crypto_amount: '',
items: [{ 'description' => 'work', 'qty' => '1', 'unit_price' => '100' }]
}.freeze
@ -109,7 +122,17 @@ class InvoiceValidationTest < Minitest::Test
assert_includes validate(items: [{ 'description' => '', 'qty' => '1', 'unit_price' => '1' }]), :items
end
def test_ltc_address_format
assert_includes validate(ltc_address: 'not-an-address'), :ltc_address
def test_crypto_address_format
assert_includes validate(crypto_address: 'not!an@address'), :crypto_address
end
def test_crypto_unknown_coin_rejected
v = validate(crypto_coin: 'DOGE', crypto_address: 'D6exampleexampleexampleexample')
assert_includes v, :crypto_coin
end
def test_crypto_valid_asset_passes
v = validate(crypto_coin: 'USDT-TRC20', crypto_address: 'TXYZexampleexampleexampleexample12')
assert_empty v
end
end

View file

@ -83,28 +83,39 @@
<textarea id="notes" name="notes" rows="3" maxlength="1000"><%= @form_values[:notes] %></textarea>
</div>
<h2>Litecoin payment <span class="optional">(optional)</span></h2>
<p class="optional" style="margin-top:-6px;">Leave the address blank to omit the LTC block and QR from the invoice.</p>
<div class="field">
<label for="ltc_address">LTC address</label>
<input type="text" id="ltc_address" name="ltc_address"
value="<%= @form_values[:ltc_address] || ENV['LTC_ADDRESS'] %>"
placeholder="ltc1..." maxlength="90" />
<h2>Crypto payment <span class="optional">(optional)</span></h2>
<p class="optional" style="margin-top:-6px;">Leave the address blank to omit the crypto block and QR from the invoice.</p>
<div class="invoice-grid">
<div class="field">
<label for="crypto_coin">Coin</label>
<select id="crypto_coin" name="crypto_coin">
<% CryptoAsset::CODES.each do |c| %>
<option value="<%= c %>" <%= 'selected' if (@form_values[:crypto_coin] || 'LTC') == c %>><%= c %> — <%= CryptoAsset.name(c) %></option>
<% end %>
</select>
</div>
<div class="field">
<label for="crypto_address">Address</label>
<input type="text" id="crypto_address" name="crypto_address"
value="<%= @form_values[:crypto_address] || CryptoAsset.default_addresses[@form_values[:crypto_coin] || 'LTC'] %>"
placeholder="payout address" maxlength="128" />
</div>
</div>
<div class="invoice-grid">
<div class="field">
<label for="ltc_rate">LTC rate (1 LTC in <span id="ltc-rate-ccy">currency</span>)</label>
<input type="text" id="ltc_rate" name="ltc_rate" value="<%= @form_values[:ltc_rate] %>"
<label for="crypto_rate">Rate (1 <span id="crypto-rate-coin">coin</span> in <span id="crypto-rate-ccy">currency</span>)</label>
<input type="text" id="crypto_rate" name="crypto_rate" value="<%= @form_values[:crypto_rate] %>"
placeholder="e.g. 85.20" inputmode="decimal" />
<button type="button" id="fetch-ltc-rate" class="link-button">↻ Fetch live</button>
<span id="ltc-rate-status" class="optional"></span>
<button type="button" id="fetch-crypto-rate" class="link-button">↻ Fetch live</button>
<span id="crypto-rate-status" class="optional"></span>
</div>
<div class="field">
<label for="ltc_amount">LTC amount due <span class="optional">(auto from rate; editable)</span></label>
<input type="text" id="ltc_amount" name="ltc_amount" value="<%= @form_values[:ltc_amount] %>"
<label for="crypto_amount">Amount due <span class="optional">(auto from rate; editable)</span></label>
<input type="text" id="crypto_amount" name="crypto_amount" value="<%= @form_values[:crypto_amount] %>"
placeholder="auto" inputmode="decimal" />
</div>
</div>
<script type="application/json" id="crypto-defaults"><%== CryptoAsset.default_addresses.to_json %></script>
<button type="submit">Create invoice</button>
</form>

View file

@ -13,11 +13,11 @@
<% if @invoice.paid_at %>
<dt>Paid at</dt><dd class="mono"><%= @invoice.paid_at.utc.strftime('%Y-%m-%d %H:%M UTC') %></dd>
<% end %>
<% if @invoice.ltc? %>
<dt>LTC</dt>
<% if @invoice.crypto? %>
<dt><%= @invoice.crypto_coin %></dt>
<dd class="mono">
<% if (amt = @invoice.ltc_amount_due) %><%= amt.to_s('F').sub(/\.?0+$/, '') %> LTC<% if @invoice.ltc_rate %> @ <%= @invoice.ltc_rate.to_s('F').sub(/(\.\d*?)0+$/, '\1').sub(/\.$/, '') %> <%= @invoice.currency %><% end %><br /><% end %>
<span style="word-break:break-all;"><%= @invoice.ltc_address %></span>
<% if (amt = @invoice.crypto_amount_due) %><%= fmt_crypto(amt) %> <%= @invoice.crypto_coin %><% if @invoice.crypto_rate %> @ <%= fmt_rate(@invoice.crypto_rate) %> <%= @invoice.currency %><% end %><br /><% end %>
<span style="word-break:break-all;"><%= @invoice.crypto_address %></span>
</dd>
<% end %>
</dl>

View file

@ -12,8 +12,8 @@
<% unless @invoice.currency == 'GEL' %>
<dt>In GEL</dt><dd class="mono">GEL <%= '%.2f' % @invoice.total_gel %> <span class="optional">@ <%= fmt_rate(@invoice.gel_rate) %></span></dd>
<% end %>
<% if @invoice.ltc? && (ltc = @invoice.ltc_amount_due) %>
<dt>In LTC</dt><dd class="mono"><%= fmt_ltc(ltc) %> LTC<% if @invoice.ltc_rate %> <span class="optional">@ <%= fmt_rate(@invoice.ltc_rate) %> <%= @invoice.currency %></span><% end %></dd>
<% if @invoice.crypto? && (amt = @invoice.crypto_amount_due) %>
<dt>In <%= @invoice.crypto_coin %></dt><dd class="mono"><%= fmt_crypto(amt) %> <%= @invoice.crypto_coin %><% if @invoice.crypto_rate %> <span class="optional">@ <%= fmt_rate(@invoice.crypto_rate) %> <%= @invoice.currency %></span><% end %></dd>
<% end %>
<dt>Status</dt><dd><span class="badge badge-<%= @invoice.status %>"><%= @invoice.status %></span></dd>
</dl>