From 0fbb6ee8091f62f677b1eb43e82ad54b858f2774 Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Fri, 3 Jul 2026 02:01:57 +0400 Subject: [PATCH] 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. --- .env.example | 7 ++- .forgejo/workflows/deploy.yaml | 4 +- CLAUDE.md | 11 +++-- asxpio.rb | 43 +++++++++-------- db/migrations/003_invoice_crypto.rb | 22 +++++++++ lib/crypto_asset.rb | 64 ++++++++++++++++++++++++++ lib/crypto_qr.rb | 33 ++++++++++++++ lib/{ltc_rate.rb => crypto_rate.rb} | 30 ++++++------ lib/fmt.rb | 4 +- lib/invoice.rb | 44 ++++++++++-------- lib/invoice_pdf.rb | 33 +++++++++----- lib/ltc_qr.rb | 30 ------------ public/admin-invoice-form.js | 43 +++++++++++++---- test/app_test.rb | 18 +++++++- test/crypto_test.rb | 71 +++++++++++++++++++++++++++++ test/fmt_test.rb | 8 ++-- test/invoice_test.rb | 39 ++++++++++++---- views/admin/invoices/new.erb | 37 +++++++++------ views/admin/invoices/show.erb | 8 ++-- views/invoice_public.erb | 4 +- 20 files changed, 410 insertions(+), 143 deletions(-) create mode 100644 db/migrations/003_invoice_crypto.rb create mode 100644 lib/crypto_asset.rb create mode 100644 lib/crypto_qr.rb rename lib/{ltc_rate.rb => crypto_rate.rb} (59%) delete mode 100644 lib/ltc_qr.rb create mode 100644 test/crypto_test.rb diff --git a/.env.example b/.env.example index 0de509d..e7680f1 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,9 @@ SMTP_PASSWORD=change-me MAIL_FROM="IE Sergei Poljanski Contact Form " 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... diff --git a/.forgejo/workflows/deploy.yaml b/.forgejo/workflows/deploy.yaml index ceff24c..d440b94 100644 --- a/.forgejo/workflows/deploy.yaml +++ b/.forgejo/workflows/deploy.yaml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 4eb5e66..6515b87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/-.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:?amount=` 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=¤cy=&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. | diff --git a/asxpio.rb b/asxpio.rb index 802640f..e4835b4 100644 --- a/asxpio.rb +++ b/asxpio.rb @@ -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 diff --git a/db/migrations/003_invoice_crypto.rb b/db/migrations/003_invoice_crypto.rb new file mode 100644 index 0000000..9610c68 --- /dev/null +++ b/db/migrations/003_invoice_crypto.rb @@ -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 diff --git a/lib/crypto_asset.rb b/lib/crypto_asset.rb new file mode 100644 index 0000000..8cf9480 --- /dev/null +++ b/lib/crypto_asset.rb @@ -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:?= (wallet prefills amount) +# scheme only => scheme: (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 diff --git a/lib/crypto_qr.rb b/lib/crypto_qr.rb new file mode 100644 index 0000000..03fdb2d --- /dev/null +++ b/lib/crypto_qr.rb @@ -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 diff --git a/lib/ltc_rate.rb b/lib/crypto_rate.rb similarity index 59% rename from lib/ltc_rate.rb rename to lib/crypto_rate.rb index 53d5e27..8c5fa85 100644 --- a/lib/ltc_rate.rb +++ b/lib/crypto_rate.rb @@ -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 diff --git a/lib/fmt.rb b/lib/fmt.rb index c1765fe..0a99b5c 100644 --- a/lib/fmt.rb +++ b/lib/fmt.rb @@ -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 diff --git a/lib/invoice.rb b/lib/invoice.rb index 3eb6961..8f9761d 100644 --- a/lib/invoice.rb +++ b/lib/invoice.rb @@ -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) diff --git a/lib/invoice_pdf.rb b/lib/invoice_pdf.rb index 42ce08d..2733919 100644 --- a/lib/invoice_pdf.rb +++ b/lib/invoice_pdf.rb @@ -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) diff --git a/lib/ltc_qr.rb b/lib/ltc_qr.rb deleted file mode 100644 index 0741381..0000000 --- a/lib/ltc_qr.rb +++ /dev/null @@ -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:
?amount= -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 diff --git a/public/admin-invoice-form.js b/public/admin-invoice-form.js index 5502319..336fdf0 100644 --- a/public/admin-invoice-form.js +++ b/public/admin-invoice-form.js @@ -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; } diff --git a/test/app_test.rb b/test/app_test.rb index 63c7ffa..31fc301 100644 --- a/test/app_test.rb +++ b/test/app_test.rb @@ -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) diff --git a/test/crypto_test.rb b/test/crypto_test.rb new file mode 100644 index 0000000..1a9f044 --- /dev/null +++ b/test/crypto_test.rb @@ -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 diff --git a/test/fmt_test.rb b/test/fmt_test.rb index 9ef96f5..1954b58 100644 --- a/test/fmt_test.rb +++ b/test/fmt_test.rb @@ -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 diff --git a/test/invoice_test.rb b/test/invoice_test.rb index aa0406a..cdbb500 100644 --- a/test/invoice_test.rb +++ b/test/invoice_test.rb @@ -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 diff --git a/views/admin/invoices/new.erb b/views/admin/invoices/new.erb index a3b15de..70fde90 100644 --- a/views/admin/invoices/new.erb +++ b/views/admin/invoices/new.erb @@ -83,28 +83,39 @@ -

Litecoin payment (optional)

-

Leave the address blank to omit the LTC block and QR from the invoice.

-
- - +

Crypto payment (optional)

+

Leave the address blank to omit the crypto block and QR from the invoice.

+
+
+ + +
+
+ + +
- - Rate (1 coin in currency) + - - + +
- - Amount due (auto from rate; editable) +
+ diff --git a/views/admin/invoices/show.erb b/views/admin/invoices/show.erb index 7835efc..610dd55 100644 --- a/views/admin/invoices/show.erb +++ b/views/admin/invoices/show.erb @@ -13,11 +13,11 @@ <% if @invoice.paid_at %>
Paid at
<%= @invoice.paid_at.utc.strftime('%Y-%m-%d %H:%M UTC') %>
<% end %> - <% if @invoice.ltc? %> -
LTC
+ <% if @invoice.crypto? %> +
<%= @invoice.crypto_coin %>
- <% 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 %>
<% end %> - <%= @invoice.ltc_address %> + <% if (amt = @invoice.crypto_amount_due) %><%= fmt_crypto(amt) %> <%= @invoice.crypto_coin %><% if @invoice.crypto_rate %> @ <%= fmt_rate(@invoice.crypto_rate) %> <%= @invoice.currency %><% end %>
<% end %> + <%= @invoice.crypto_address %>
<% end %> diff --git a/views/invoice_public.erb b/views/invoice_public.erb index dec0191..3e393ec 100644 --- a/views/invoice_public.erb +++ b/views/invoice_public.erb @@ -12,8 +12,8 @@ <% unless @invoice.currency == 'GEL' %>
In GEL
GEL <%= '%.2f' % @invoice.total_gel %> @ <%= fmt_rate(@invoice.gel_rate) %>
<% end %> - <% if @invoice.ltc? && (ltc = @invoice.ltc_amount_due) %> -
In LTC
<%= fmt_ltc(ltc) %> LTC<% if @invoice.ltc_rate %> @ <%= fmt_rate(@invoice.ltc_rate) %> <%= @invoice.currency %><% end %>
+ <% if @invoice.crypto? && (amt = @invoice.crypto_amount_due) %> +
In <%= @invoice.crypto_coin %>
<%= fmt_crypto(amt) %> <%= @invoice.crypto_coin %><% if @invoice.crypto_rate %> @ <%= fmt_rate(@invoice.crypto_rate) %> <%= @invoice.currency %><% end %>
<% end %>
Status
<%= @invoice.status %>