From 33ba7650c38abc5bf07eab346a2c1d2b251df243 Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Sat, 10 Oct 2026 09:17:09 +0400 Subject: [PATCH] invoices: self-contained payout addresses Crypto options were limited to a hard-coded asset list, with one address per coin in a side table. A payout address now carries everything an invoice needs: ticker, name, QR URI scheme, amount parameter, CoinGecko id, address and an active flag. Any coin can be added, several addresses per ticker are fine, and known tickers fill the QR and rate fields from the built-in templates. The new-invoice form picks a payout address. The invoice snapshots the ticker, address, name, scheme and amount parameter, and the PDF and QR render from that copy, so editing, retiring or deleting a payout entry never changes an issued invoice. "Fetch live" looks up the rate by the payout address's CoinGecko id. Migration 012 keeps the existing rows and backfills both the payout metadata and the snapshots on existing crypto invoices. Assisted-by: Claude:opus-5 --- CLAUDE.md | 2 +- asxpio.rb | 73 ++++++++++++------- db/migrations/012_payout_addresses_rework.rb | 51 ++++++++++++++ lib/crypto_asset.rb | 20 ++++-- lib/crypto_qr.rb | 19 +++-- lib/crypto_rate.rb | 9 ++- lib/invoice.rb | 12 +++- lib/invoice_pdf.rb | 11 +-- lib/invoice_stats.rb | 4 +- lib/payout_address.rb | 71 ++++++++++++++----- public/admin-invoice-form.js | 33 ++++----- public/style.css | 13 ++++ test/app_test.rb | 16 +++-- test/crypto_test.rb | 25 +++---- test/invoice_test.rb | 58 ++++++++++----- test/payout_address_test.rb | 71 ++++++++++++++----- views/admin/invoices/new.erb | 30 ++++---- views/admin/invoices/payout_addresses.erb | 74 +++++++++++++++----- views/admin/invoices/stats.erb | 4 +- 19 files changed, 420 insertions(+), 176 deletions(-) create mode 100644 db/migrations/012_payout_addresses_rework.rb diff --git a/CLAUDE.md b/CLAUDE.md index 68758d2..4efce42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,7 +105,7 @@ Public: ### Crypto payment -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 `payout_addresses` table, managed at `/admin/invoices/payout-addresses`; seeded once from the old `CRYPTO_ADDRESSES`/`LTC_ADDRESS` env by `PayoutAddress.seed_from_env!`), 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). +Opt-in per invoice. Crypto options are **payout addresses** (`payout_addresses`, `lib/payout_address.rb`, edited at `/admin/invoices/payout-addresses`): each row is self-contained — ticker (`coin`, printed on the PDF), name, QR URI scheme (`litecoin`; NULL = bare address), amount parameter (`amount`/`tx_amount`/NULL), CoinGecko id (NULL = rate typed by hand), address, active. Several per ticker are fine; any coin can be added. `CryptoAsset::DEFAULTS` is only a template set (prefills those fields for a known ticker, backfilled migration 012, names legacy invoices) plus the one-time env seed (`PayoutAddress.seed_from_env!` from `CRYPTO_ADDRESSES`/`LTC_ADDRESS`). The new-invoice form picks a payout address; the invoice **snapshots** `crypto_coin`, `crypto_address`, `crypto_name`, `crypto_scheme`, `crypto_amount_param`, so editing, retiring or deleting a payout entry never changes issued invoices. The form has a "Fetch live" button: `GET /admin/crypto-rate?payout=¤cy=&gel_rate=` returns the price via `lib/crypto_rate.rb` (CoinGecko, no API key; USD/EUR direct, GEL derived from the form's `gel_rate`). `lib/crypto_qr.rb` renders the QR from the invoice's snapshot: `scheme:address?param=amount`, scheme-only, or the bare address (the PDF then prints a "verify the network" hint). ### PDF rendering diff --git a/asxpio.rb b/asxpio.rb index 12e2dcf..a3a3c06 100644 --- a/asxpio.rb +++ b/asxpio.rb @@ -182,17 +182,18 @@ class AsxpioWeb < Sinatra::Base erb :'admin/invoices/index' end - # 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. + # Live price for the new-invoice form's "Fetch live" button, using the + # chosen payout address's CoinGecko id. 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 + payout = PayoutAddress[params[:payout].to_i] currency = params[:currency].to_s.upcase gel_rate = params[:gel_rate].to_s.strip + halt 404, { error: 'Unknown payout address' }.to_json unless payout begin - 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 + rate = CryptoRate.fetch(payout.coingecko, currency, gel_rate: gel_rate.empty? ? nil : gel_rate) + { rate: rate.round(8).to_s('F'), coin: payout.coin, currency: currency }.to_json rescue CryptoRate::Error => e status 502 { error: e.message }.to_json @@ -235,20 +236,43 @@ class AsxpioWeb < Sinatra::Base get '/admin/invoices/payout-addresses' do @page_title = 'Payout addresses · admin' @noindex = true - @addresses = PayoutAddress.as_hash(:coin) + @rows = PayoutAddress.listing @flash = session.delete(:payout_flash) erb :'admin/invoices/payout_addresses' end + # One form edits every row (payout[id][...]); payout[new][...] adds one, + # filling empty fields from the built-in template for a known ticker. post '/admin/invoices/payout-addresses' do - before = PayoutAddress.map - errors = CryptoAsset::CODES.filter_map do |coin| - next unless params.dig('address', coin) - PayoutAddress.set!(coin, params['address'][coin], by: current_user.username) + errors, changed = [], [] + (params['payout'] || {}).each do |id, raw| + fresh = id == 'new' + next if fresh && raw['address'].to_s.strip.empty? + v = PayoutAddress.clean(raw, fill_from_template: fresh) + if (e = PayoutAddress.error_for(v)) then errors << e; next end + stamp = { updated_by: current_user.username, updated_at: Time.now.utc } + if fresh + row = PayoutAddress.create(**v, active: true, **stamp) + changed << "+#{row.coin} #{row.name}" + else + row = PayoutAddress[id.to_i] or next + row.set(**v, active: raw['active'] == '1') + next unless row.modified? + row.set(**stamp).save_changes + changed << "#{row.coin} #{row.name}" + end end - changed = (before.keys | PayoutAddress.map.keys).reject { |c| before[c] == PayoutAddress.map[c] } - audit!('invoice.payout_addresses', changed.join(', '), nil) if changed.any? - session[:payout_flash] = errors.any? ? errors.join('; ') : "Saved#{" (#{changed.join(', ')})" if changed.any?}." + audit!('invoice.payout_addresses', nil, changed.join('; ')) if changed.any? + session[:payout_flash] = errors.any? ? errors.join('; ') : (changed.any? ? 'Saved.' : 'Nothing changed.') + redirect '/admin/invoices/payout-addresses' + end + + # Existing invoices keep their own copy, so removal is safe. + post '/admin/invoices/payout-addresses/:id/delete' do + row = PayoutAddress[params[:id].to_i] or halt 404 + row.destroy + audit!('invoice.payout_address_delete', row.coin, "#{row.name} #{row.address}") + session[:payout_flash] = "Removed #{row.coin} · #{row.name}." redirect '/admin/invoices/payout-addresses' end @@ -280,8 +304,7 @@ class AsxpioWeb < Sinatra::Base issued_on: Date.today.strftime('%Y-%m-%d'), due_on: (Date.today + term).strftime('%Y-%m-%d'), notes: src.notes, - crypto_coin: src.crypto_coin, - crypto_address: src.crypto_address, + payout_id: PayoutAddress.first(coin: src.crypto_coin, address: src.crypto_address, active: true)&.id.to_s, items: src.items_array } erb :'admin/invoices/new' @@ -297,8 +320,7 @@ 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, - crypto_coin: params[:crypto_coin].to_s.upcase, - crypto_address: params[:crypto_address].to_s.strip, + payout_id: params[:payout_id].to_s.strip, crypto_rate: params[:crypto_rate].to_s.strip, crypto_amount: params[:crypto_amount].to_s.strip, items: (params[:items] || {}).values @@ -317,9 +339,11 @@ class AsxpioWeb < Sinatra::Base # The PDF embeds the number, so a retry must re-render, not just re-save. # A losing attempt orphans its two S3 objects; harmless and near-impossible # with a single operator. + build_params = @form_values.merge(@payout ? @payout.snapshot : {}) + attempts = 0 begin - invoice = Invoice.build(@form_values) + invoice = Invoice.build(build_params) # Base key (no suffix); the two status variants get -pending/-paid appended. invoice.pdf_key = "invoices/#{invoice.number}-#{invoice.uuid}.pdf" %w[pending paid].each do |st| @@ -441,11 +465,12 @@ class AsxpioWeb < Sinatra::Base end v[:items] = items - # 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/ + # Crypto is optional: a chosen payout address (validated when it was + # saved) supplies ticker, address and QR metadata. + @payout = nil + unless v[:payout_id].to_s.empty? + @payout = PayoutAddress.first(id: v[:payout_id].to_i, active: true) + errors[:payout_id] = 'Pick an active payout address' unless @payout %i[crypto_rate crypto_amount].each do |k| next if v[k].to_s.strip.empty? begin diff --git a/db/migrations/012_payout_addresses_rework.rb b/db/migrations/012_payout_addresses_rework.rb new file mode 100644 index 0000000..c389e4f --- /dev/null +++ b/db/migrations/012_payout_addresses_rework.rb @@ -0,0 +1,51 @@ +require_relative '../../lib/crypto_asset' + +Sequel.migration do + up do + # A payout address now carries everything an invoice needs: ticker + # (coin), display name, QR URI scheme + amount parameter, CoinGecko id for + # the live rate, the address, and an active flag. Several per ticker. + alter_table(:payout_addresses) do + drop_constraint :payout_addresses_pkey + add_primary_key :id + add_column :name, String + add_column :scheme, String # e.g. litecoin; NULL = bare address + add_column :amount_param, String # amount | tx_amount; NULL = none + add_column :coingecko, String # NULL = rate typed by hand + add_column :active, TrueClass, null: false, default: true + end + + # Invoices snapshot the payout metadata at issue (like the address), so + # later edits never change an issued PDF or QR. + alter_table(:invoices) do + add_column :crypto_name, String + add_column :crypto_scheme, String + add_column :crypto_amount_param, String + end + + CryptoAsset::DEFAULTS.each do |code, a| + self[:payout_addresses].where(coin: code).update(name: a[:name], scheme: a[:scheme], + amount_param: a[:amount_param], coingecko: a[:coingecko]) + self[:invoices].where(crypto_coin: code).update(crypto_name: a[:name], crypto_scheme: a[:scheme], + crypto_amount_param: a[:amount_param]) + end + self[:payout_addresses].where(name: nil).update(name: :coin) + end + + down do + alter_table(:invoices) do + drop_column :crypto_amount_param + drop_column :crypto_scheme + drop_column :crypto_name + end + alter_table(:payout_addresses) do + drop_column :active + drop_column :coingecko + drop_column :amount_param + drop_column :scheme + drop_column :name + drop_column :id + add_primary_key [:coin] + end + end +end diff --git a/lib/crypto_asset.rb b/lib/crypto_asset.rb index 78595d5..6b651d9 100644 --- a/lib/crypto_asset.rb +++ b/lib/crypto_asset.rb @@ -1,8 +1,10 @@ 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). +# Built-in crypto templates. Payout addresses (payout_addresses table) carry +# their own ticker, name, QR scheme, amount parameter and CoinGecko id; these +# entries only prefill those fields for a known ticker, back-fill rows from +# before that, and name legacy invoices. Nothing here limits which coins can +# be used. # # QR encoding is driven by scheme/amount_param: # scheme + amount_param => scheme:?= (wallet prefills amount) @@ -12,7 +14,7 @@ require 'json' # coingecko is the id for the simple/price endpoint; chain variants of a # stablecoin share the token's id. module CryptoAsset - ASSETS = { + DEFAULTS = { '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 }, @@ -29,7 +31,15 @@ module CryptoAsset 'USDC-ALGO' => { name: 'USD Coin (Algorand)', coingecko: 'usd-coin', scheme: nil, amount_param: nil } }.freeze - CODES = ASSETS.keys.freeze + ASSETS = DEFAULTS # older name + CODES = DEFAULTS.keys.freeze + + TICKER_RE = /\A[A-Z0-9][A-Z0-9-]{1,19}\z/ + SCHEME_RE = /\A[a-z][a-z0-9+.-]{1,31}\z/ + AMOUNT_PARAMS = %w[amount tx_amount].freeze + # Suggestions for the admin form; wallets only honour real URI schemes. + KNOWN_SCHEMES = (DEFAULTS.values.filter_map { |a| a[:scheme] } + + %w[dogecoin bitcoincash dash zcash tron ton]).uniq.freeze module_function diff --git a/lib/crypto_qr.rb b/lib/crypto_qr.rb index 03fdb2d..08024f0 100644 --- a/lib/crypto_qr.rb +++ b/lib/crypto_qr.rb @@ -2,25 +2,24 @@ 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. +# The payload follows the payout entry's scheme/amount parameter: 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? + def uri(scheme:, amount_param:, address:, amount:) + return address unless scheme + base = "#{scheme}:#{address}" + return base if amount.nil? || 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}" + "#{base}?#{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) + def png(size: 480, **payload) + qr = RQRCode::QRCode.new(uri(**payload), level: :m) qr.as_png( bit_depth: 1, border_modules: 2, diff --git a/lib/crypto_rate.rb b/lib/crypto_rate.rb index 8c5fa85..1585fc9 100644 --- a/lib/crypto_rate.rb +++ b/lib/crypto_rate.rb @@ -2,7 +2,6 @@ require 'net/http' require 'json' require 'uri' require 'bigdecimal' -require_relative 'crypto_asset' # Fetches the current price of a CryptoAsset from CoinGecko (free, no API key). # @@ -17,11 +16,11 @@ module CryptoRate module_function - # Returns a BigDecimal: the price of 1 unit of `coin` in `currency`. + # Returns a BigDecimal: the price of 1 unit of the CoinGecko asset `id` + # (a payout address's coingecko field) in `currency`. # `gel_rate` (USD->GEL) is required only when currency == 'GEL'. - def fetch(coin, currency, gel_rate: nil) - asset = CryptoAsset[coin] or raise Error, "Unknown coin: #{coin}" - id = asset[:coingecko] + def fetch(id, currency, gel_rate: nil) + raise Error, 'No CoinGecko id set for this payout address; enter the rate by hand.' if id.to_s.empty? currency = currency.to_s.upcase if currency == 'GEL' diff --git a/lib/invoice.rb b/lib/invoice.rb index e69f6ed..7a8b848 100644 --- a/lib/invoice.rb +++ b/lib/invoice.rb @@ -103,7 +103,10 @@ class Invoice < Sequel::Model(:invoices) private - # Crypto is opt-in: only populated when an address is given. Rate and + # Crypto is opt-in: only populated when an address is given (the route + # fills these from the chosen PayoutAddress#snapshot). Name/scheme/amount + # param are copied so later payout edits don't change this invoice; a + # known ticker without them falls back to the built-in template. Rate and # amount are both optional; if amount is blank but rate is present, # derive it. def apply_crypto(invoice, params, subtotal) @@ -111,10 +114,15 @@ class Invoice < Sequel::Model(:invoices) return if address.empty? coin = params[:crypto_coin].to_s.upcase - CryptoAsset.valid?(coin) or raise ArgumentError, "Unknown coin: #{coin}" + coin.match?(CryptoAsset::TICKER_RE) or raise ArgumentError, "Bad ticker: #{coin}" + tmpl = CryptoAsset::DEFAULTS[coin] || {} + pick = ->(k, t) { params.key?(k) ? params[k].to_s.strip.then { |v| v.empty? ? nil : v } : tmpl[t] } invoice.crypto_coin = coin invoice.crypto_address = address + invoice.crypto_name = pick.(:crypto_name, :name) || coin + invoice.crypto_scheme = pick.(:crypto_scheme, :scheme) + invoice.crypto_amount_param = pick.(:crypto_amount_param, :amount_param) rate = decimal_or_nil(params[:crypto_rate]) invoice.crypto_rate = rate diff --git a/lib/invoice_pdf.rb b/lib/invoice_pdf.rb index ee115bb..abd1ea2 100644 --- a/lib/invoice_pdf.rb +++ b/lib/invoice_pdf.rb @@ -289,20 +289,21 @@ class InvoicePdf def draw_crypto(pdf) coin = @invoice.crypto_coin - asset = CryptoAsset[coin] || {} + name = @invoice.crypto_name || CryptoAsset.name(coin) # snapshot; legacy fallback 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 #{CryptoAsset.name(coin).upcase} (#{coin})", character_spacing: 1.5 } + pdf.font_size(8) { pdf.text "PAY IN #{name.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 = CryptoQr.png(coin, @invoice.crypto_address, amount) + qr_bytes = CryptoQr.png(scheme: @invoice.crypto_scheme, amount_param: @invoice.crypto_amount_param, + address: @invoice.crypto_address, amount: 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 @@ -325,8 +326,8 @@ class InvoicePdf pdf.font_size(8) { pdf.text @invoice.crypto_address } pdf.move_down 3 pdf.fill_color COLOR_MUTED - hint = if asset[:amount_param] - "Scan the QR with any #{CryptoAsset.name(coin)} wallet to prefill the payment." + hint = if @invoice.crypto_scheme && @invoice.crypto_amount_param + "Scan the QR with any #{name} wallet to prefill the payment." else "QR encodes the address. Send #{coin} only. Verify the network before sending." end diff --git a/lib/invoice_stats.rb b/lib/invoice_stats.rb index d3e487c..8264fc3 100644 --- a/lib/invoice_stats.rb +++ b/lib/invoice_stats.rb @@ -61,7 +61,9 @@ class InvoiceStats live.group_by(&:client_name).map { |c, l| [c, l.size, gel(l)] }.sort_by { |_, _, g| -g }.first(n) end + # [[ticker, name, invoices]] — name from the invoices' own snapshot. def crypto_usage - live.select(&:crypto?).group_by(&:crypto_coin).map { |c, l| [c, l.size] }.sort_by { |_, n| -n } + live.select(&:crypto?).group_by(&:crypto_coin) + .map { |c, l| [c, l.filter_map(&:crypto_name).last || c, l.size] }.sort_by { |_, _, n| -n } end end diff --git a/lib/payout_address.rb b/lib/payout_address.rb index 1a63605..8a42c0e 100644 --- a/lib/payout_address.rb +++ b/lib/payout_address.rb @@ -1,36 +1,71 @@ require 'sequel' require_relative 'crypto_asset' -# Default payout address per coin (see migration 010). +# A crypto payout option, self-contained: ticker (`coin`, printed on the +# PDF), display name, QR URI scheme + amount parameter, CoinGecko id for the +# live rate, and the address. Several per ticker are fine (e.g. Ledger and +# exchange). The new-invoice form picks one; the invoice copies its fields +# (#snapshot), so editing or retiring an entry never changes issued invoices. class PayoutAddress < Sequel::Model(:payout_addresses) - unrestrict_primary_key - ADDRESS_RE = /\A[a-zA-Z0-9]{10,128}\z/ # same loose shape check as invoices + ADDRESS_RE = /\A[a-zA-Z0-9]{10,128}\z/ # loose shape check, same as invoices + FIELDS = %i[name coin scheme amount_param coingecko address].freeze - def self.map - to_hash(:coin, :address).slice(*CryptoAsset::CODES) + def self.listing + order(Sequel.desc(:active), :coin, :name, :id).all end - # First boot after the move: take over what the env/Forgejo secrets held. + def self.active_list + where(active: true).order(:coin, :name, :id).all + end + + # First boot after moving off env: import CRYPTO_ADDRESSES / LTC_ADDRESS. def self.seed_from_env! return if count.positive? CryptoAsset.env_addresses.each do |coin, address| next unless address.match?(ADDRESS_RE) - create(coin: coin, address: address, updated_by: 'env import', updated_at: Time.now.utc) + create(**template(coin), coin: coin, address: address, updated_by: 'env import', updated_at: Time.now.utc) end end - # Upsert or (blank address) delete. Returns an error string or nil. - def self.set!(coin, address, by:) - coin = coin.to_s.upcase - return 'unknown coin' unless CryptoAsset.valid?(coin) - address = address.to_s.strip - if address.empty? - where(coin: coin).delete - return nil + # Known ticker → its usual name/scheme/amount param/CoinGecko id. + def self.template(coin) + a = CryptoAsset::DEFAULTS[coin.to_s.upcase] or return {} + { name: a[:name], scheme: a[:scheme], amount_param: a[:amount_param], coingecko: a[:coingecko] } + end + + # Cleans form input: blanks → nil, scheme lowercased without a trailing + # colon, ticker uppercased; empty optional fields are taken from the + # template when the ticker is a known one. + def self.clean(raw, fill_from_template: false) + v = FIELDS.to_h { |f| [f, raw[f.to_s].to_s.strip.then { |x| x.empty? ? nil : x }] } + v[:coin] = v[:coin]&.upcase + v[:scheme] = v[:scheme]&.downcase&.delete_suffix(':') + v[:coingecko] = v[:coingecko]&.downcase + if fill_from_template + template(v[:coin]).each { |k, t| v[k] ||= t } end - return "#{coin}: address looks invalid" unless address.match?(ADDRESS_RE) - row = self[coin] || new(coin: coin) - row.set(address: address, updated_by: by, updated_at: Time.now.utc).save + v[:name] ||= v[:coin] + v + end + + def self.error_for(v) + label = v[:coin] || 'new entry' + return "#{label}: ticker must be A–Z, 0–9 and -, 2–20 characters" unless v[:coin].to_s.match?(CryptoAsset::TICKER_RE) + return "#{label}: address looks invalid" unless v[:address].to_s.match?(ADDRESS_RE) + return "#{label}: scheme must look like 'litecoin' (full coin name, lowercase)" if v[:scheme] && !v[:scheme].match?(CryptoAsset::SCHEME_RE) + return "#{label}: unknown amount parameter" if v[:amount_param] && !CryptoAsset::AMOUNT_PARAMS.include?(v[:amount_param]) + return "#{label}: amount in QR needs a scheme" if v[:amount_param] && !v[:scheme] + return "#{label}: CoinGecko id looks invalid" if v[:coingecko] && !v[:coingecko].match?(/\A[a-z0-9-]{2,64}\z/) nil end + + # What an invoice copies at issue (Invoice.build reads these params). + def snapshot + { crypto_coin: coin, crypto_address: address, crypto_name: name, + crypto_scheme: scheme, crypto_amount_param: amount_param } + end + + def option_label + "#{name} · #{coin} · #{address[0, 10]}…" + end end diff --git a/public/admin-invoice-form.js b/public/admin-invoice-form.js index 336fdf0..06b6e55 100644 --- a/public/admin-invoice-form.js +++ b/public/admin-invoice-form.js @@ -36,9 +36,8 @@ reindex(); }); - // --- Crypto: coin select, live rate fetch + auto amount ------------- - const coinSel = document.getElementById('crypto_coin'); - const addressInput = document.getElementById('crypto_address'); + // --- Crypto: payout address, live rate fetch + auto amount ---------- + const payoutSel = document.getElementById('payout_id'); const rateInput = document.getElementById('crypto_rate'); const amountInput = document.getElementById('crypto_amount'); const fetchBtn = document.getElementById('fetch-crypto-rate'); @@ -48,20 +47,13 @@ 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 */ } + function selectedPayout() { + return payoutSel ? payoutSel.selectedOptions[0] : null; } - // 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] || ''; + // A different payout address means a different coin: stale rate/amount out. + if (payoutSel) { + payoutSel.addEventListener('change', function () { rateInput.value = ''; if (amountInput) amountInput.value = ''; amountTouched = false; @@ -99,7 +91,11 @@ const ccy = currencySel ? currencySel.value : 'USD'; if (rateCcy) rateCcy.textContent = ccy; if (gelRateCcy) gelRateCcy.textContent = ccy; - if (rateCoin && coinSel) rateCoin.textContent = coinSel.value; + const opt = selectedPayout(); + const cryptoFields = document.getElementById('crypto-fields'); + if (cryptoFields) cryptoFields.hidden = !(opt && opt.value); // rate/amount only with a payout address + if (rateCoin) rateCoin.textContent = (opt && opt.dataset.coin) || 'coin'; + if (fetchBtn) fetchBtn.hidden = !(opt && opt.dataset.rate); // no CoinGecko id: type the rate } syncCcyLabel(); if (currencySel) currencySel.addEventListener('change', syncCcyLabel); @@ -110,8 +106,9 @@ if (fetchBtn) { fetchBtn.addEventListener('click', function () { const ccy = currencySel ? currencySel.value : 'USD'; - const coin = coinSel ? coinSel.value : 'LTC'; - const params = new URLSearchParams({ coin: coin, currency: ccy }); + const opt = selectedPayout(); + if (!opt || !opt.value) { rateStatus.textContent = ' choose a payout address first'; return; } + const params = new URLSearchParams({ payout: opt.value, currency: ccy }); if (gelRateInput && gelRateInput.value) params.set('gel_rate', gelRateInput.value); rateStatus.textContent = ' fetching…'; fetch('/admin/crypto-rate?' + params.toString(), { headers: { Accept: 'application/json' } }) diff --git a/public/style.css b/public/style.css index baf8152..4ccea3d 100644 --- a/public/style.css +++ b/public/style.css @@ -381,6 +381,19 @@ fieldset.plain { border: 0; margin: 0; padding: 0; min-width: 0; } .stats b { display: block; font-size: 1.4rem; color: var(--text-bright); font-variant-numeric: tabular-nums; } .stats span { font-size: 0.75rem; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.06em; } +.payout-card { border: 1px solid var(--border); border-radius: 4px; padding: 14px 16px 6px; margin-bottom: 14px; } +.payout-card.new { border-style: dashed; } +.payout-card.inactive { opacity: 0.55; } +.payout-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 10px; color: var(--text-bright); } +.payout-head .optional { color: var(--text-faint); font-size: 0.8rem; } +.invoice-form input[type="checkbox"], .invoice-form input[type="radio"] { accent-color: var(--text-dim); } +.invoice-form [hidden] { display: none !important; } /* beats .invoice-grid's display: grid */ +.payout-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 14px; } +.payout-grid .payout-address { grid-column: 1 / -1; } +.qr-line { font-size: 0.75rem; word-break: break-all; margin: 0 0 8px; } +.payout-meta { font-family: var(--font-sans); } +@media (max-width: 560px) { .payout-grid { grid-template-columns: 1fr; } } + /* Invoice statistics */ .year-filter { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-bottom: 24px; } .year-filter a { padding: 2px 10px; border: 1px solid var(--border); border-radius: 3px; text-decoration: none; color: var(--text-dim); font-family: var(--font-mono); font-size: 0.85rem; } diff --git a/test/app_test.rb b/test/app_test.rb index 192565d..da47b6f 100644 --- a/test/app_test.rb +++ b/test/app_test.rb @@ -72,7 +72,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: '', - crypto_coin: 'LTC', crypto_address: '', crypto_rate: '', crypto_amount: '', + payout_id: '', crypto_rate: '', crypto_amount: '', items: { '0' => { 'description' => 'Engineering — test', 'qty' => '2', 'unit_price' => '100.50' } } } end @@ -94,16 +94,22 @@ class AppTest < Minitest::Test 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: '' - ) + payout = PayoutAddress.create(coin: 'USDT-TRC20', name: 'Tether (Tron)', address: 'TXYZexampleexampleexampleexample12', + updated_by: 't', updated_at: Time.now.utc) + params = invoice_form_params(token).merge(payout_id: payout.id.to_s, 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 'TXYZexampleexampleexampleexample12', invoice.crypto_address + assert_nil invoice.crypto_scheme # token: bare address QR assert_equal BigDecimal('201'), invoice.crypto_amount_due + + payout.update(address: 'TNEWexampleexampleexampleexample99', name: 'Renamed') + assert_equal 'TXYZexampleexampleexampleexample12', invoice.refresh.crypto_address # snapshot kept + ensure + payout&.destroy end def test_create_invoice_validation_failure_rerenders_form diff --git a/test/crypto_test.rb b/test/crypto_test.rb index 323237e..8e03923 100644 --- a/test/crypto_test.rb +++ b/test/crypto_test.rb @@ -43,29 +43,30 @@ class CryptoAssetTest < Minitest::Test end class CryptoQrTest < Minitest::Test + def uri(scheme, amount_param, address, amount) + CryptoQr.uri(scheme: scheme, amount_param: amount_param, address: address, amount: amount) + end + 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')) + assert_equal 'bitcoin:bc1qaddr?amount=0.005', uri('bitcoin', 'amount', 'bc1qaddr', BigDecimal('0.00500000')) + assert_equal 'litecoin:ltc1qaddr?amount=1.25', uri('litecoin', 'amount', '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')) + assert_equal 'monero:4Aaddr?tx_amount=2.5', uri('monero', 'tx_amount', '4Aaddr', BigDecimal('2.5')) end - def test_eth_scheme_without_amount - assert_equal 'ethereum:0xAddr', CryptoQr.uri('ETH', '0xAddr', BigDecimal('1.5')) + def test_scheme_without_amount_param + assert_equal 'ethereum:0xAddr', uri('ethereum', nil, '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) + def test_no_scheme_encodes_bare_address + assert_equal 'TAddr', uri(nil, nil, 'TAddr', BigDecimal('100')) + assert_equal 'AAddr', uri(nil, nil, 'AAddr', nil) end def test_png_renders - png = CryptoQr.png('BTC', 'bc1qexampleexampleexample', BigDecimal('0.01')) + png = CryptoQr.png(scheme: 'bitcoin', amount_param: 'amount', address: 'bc1qexampleexampleexample', amount: BigDecimal('0.01')) assert png.start_with?("\x89PNG".b) end end diff --git a/test/invoice_test.rb b/test/invoice_test.rb index cdbb500..d01d161 100644 --- a/test/invoice_test.rb +++ b/test/invoice_test.rb @@ -71,14 +71,32 @@ class InvoiceTest < Minitest::Test assert_equal BigDecimal('1.25'), inv.crypto_amount_due end - def test_build_rejects_unknown_coin + def test_build_fills_known_ticker_from_template + 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' }], + crypto_coin: 'ltc', crypto_address: 'ltc1qexampleexampleexampleexample' + ) + assert_equal ['Litecoin', 'litecoin', 'amount'], [inv.crypto_name, inv.crypto_scheme, inv.crypto_amount_param] + end + + # Any ticker works once a payout address defines it; the invoice keeps + # its own copy of the QR metadata. + def test_build_takes_snapshot_for_custom_coin + 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' }], + crypto_coin: 'DOGE', crypto_address: 'D6examplexampleexampleexample', + crypto_name: 'Dogecoin', crypto_scheme: 'dogecoin', crypto_amount_param: 'amount' + ) + assert_equal ['DOGE', 'Dogecoin', 'dogecoin'], [inv.crypto_coin, inv.crypto_name, inv.crypto_scheme] + end + + def test_build_rejects_malformed_ticker 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' - ) + Invoice.build(client_name: 'A', client_email: 'a@example.com', currency: 'EUR', gel_rate: '3', + items: [{ 'description' => 'w', 'qty' => '1', 'unit_price' => '1' }], + crypto_coin: 'bad coin', crypto_address: 'D6examplexampleexampleexample') end end end @@ -86,7 +104,7 @@ end class InvoiceValidationTest < Minitest::Test BASE = { client_name: 'ACME', client_email: 'billing@example.com', currency: 'EUR', - gel_rate: '3.05', crypto_coin: 'LTC', crypto_address: '', crypto_rate: '', crypto_amount: '', + gel_rate: '3.05', payout_id: '', crypto_rate: '', crypto_amount: '', items: [{ 'description' => 'work', 'qty' => '1', 'unit_price' => '100' }] }.freeze @@ -122,17 +140,21 @@ class InvoiceValidationTest < Minitest::Test assert_includes validate(items: [{ 'description' => '', 'qty' => '1', 'unit_price' => '1' }]), :items end - def test_crypto_address_format - assert_includes validate(crypto_address: 'not!an@address'), :crypto_address + def test_unknown_or_inactive_payout_rejected + assert_includes validate(payout_id: '999999'), :payout_id + p = PayoutAddress.create(coin: 'LTC', name: 'Litecoin', address: 'ltc1qvalidvalidvalidvalid', active: false, + updated_by: 't', updated_at: Time.now.utc) + assert_includes validate(payout_id: p.id.to_s), :payout_id + p.update(active: true) + assert_empty validate(payout_id: p.id.to_s) + ensure + p&.destroy 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 + def test_crypto_rate_must_be_positive + p = PayoutAddress.create(coin: 'LTC', name: 'Litecoin', address: 'ltc1qvalidvalidvalidvalid', updated_by: 't', updated_at: Time.now.utc) + assert_includes validate(payout_id: p.id.to_s, crypto_rate: '-1'), :crypto_rate + ensure + p&.destroy end end diff --git a/test/payout_address_test.rb b/test/payout_address_test.rb index 0c5be1c..6deeeae 100644 --- a/test/payout_address_test.rb +++ b/test/payout_address_test.rb @@ -14,37 +14,76 @@ class PayoutAddressTest < Minitest::Test DB.connection[:payout_addresses].delete end - def token - get '/admin/invoices/payout-addresses' + def token(path = '/admin/invoices/payout-addresses') + get path last_response.body[/name="authenticity_token" value="([^"]+)"/, 1] end + def add(fields) + post '/admin/invoices/payout-addresses', { authenticity_token: token, payout: { 'new' => fields } } + end + def test_seed_from_env_once ENV['CRYPTO_ADDRESSES'] = '{"BTC":"bc1qseedseedseedseed","XYZ":"nope00000000"}' PayoutAddress.seed_from_env! - assert_equal({ 'BTC' => 'bc1qseedseedseedseed' }, PayoutAddress.map) + rows = PayoutAddress.all + assert_equal [['BTC', 'bc1qseedseedseedseed', 'Bitcoin', 'bitcoin']], rows.map { |r| [r.coin, r.address, r.name, r.scheme] } ENV['CRYPTO_ADDRESSES'] = '{"BTC":"bc1qotherotherother"}' PayoutAddress.seed_from_env! - assert_equal 'bc1qseedseedseedseed', PayoutAddress.map['BTC'] # not re-seeded + assert_equal 1, PayoutAddress.count ensure ENV.delete('CRYPTO_ADDRESSES') end - def test_admin_edits_validates_and_removes + def test_add_known_ticker_fills_from_template_and_custom_coin admin_login! - post '/admin/invoices/payout-addresses', { authenticity_token: token, address: { 'LTC' => 'ltc1qaaaaaaaaaaaaaaaa', 'BTC' => 'bad addr!' } } - assert_equal 302, last_response.status - assert_equal 'ltc1qaaaaaaaaaaaaaaaa', PayoutAddress.map['LTC'] - assert_nil PayoutAddress.map['BTC'] + add('coin' => 'ltc', 'address' => 'ltc1qaaaaaaaaaaaaaaaa', 'name' => 'Litecoin (Ledger)') + add('coin' => 'LTC', 'address' => 'ltc1qbbbbbbbbbbbbbbbb', 'name' => 'Litecoin (Exchange)') + add('coin' => 'DOGE', 'address' => 'DAddr1234567890', 'name' => 'Dogecoin', 'scheme' => 'dogecoin:', 'amount_param' => 'amount', 'coingecko' => 'dogecoin') + add('coin' => 'BTC', 'address' => 'bad addr!') + led = PayoutAddress[address: 'ltc1qaaaaaaaaaaaaaaaa'] + assert_equal ['LTC', 'litecoin', 'amount', 'litecoin'], [led.coin, led.scheme, led.amount_param, led.coingecko] + assert_equal 2, PayoutAddress.where(coin: 'LTC').count + doge = PayoutAddress[coin: 'DOGE'] + assert_equal 'dogecoin', doge.scheme # colon stripped get '/admin/invoices/payout-addresses' assert_includes last_response.body, 'BTC: address looks invalid' - assert_includes DB.connection[:audit_log].select_map(:action), 'invoice.payout_addresses' + assert_includes last_response.body, 'QR → dogecoin:DAddr1234567890?amount=0.5' get '/admin/invoices/new' - assert_includes last_response.body, 'ltc1qaaaaaaaaaaaaaaaa' # prefilled from DB + assert_includes last_response.body, 'Litecoin (Exchange) · LTC' + assert_includes last_response.body, 'Dogecoin · DOGE' + end - post '/admin/invoices/payout-addresses', { authenticity_token: token, address: { 'LTC' => '' } } - assert_nil PayoutAddress.map['LTC'] + def test_edit_validate_retire_and_remove + admin_login! + add('coin' => 'LTC', 'address' => 'ltc1qaaaaaaaaaaaaaaaa') + row = PayoutAddress.first + post '/admin/invoices/payout-addresses', { authenticity_token: token, + payout: { row.id.to_s => { 'coin' => 'LTC', 'name' => 'Main', 'address' => row.address, 'scheme' => 'Lite Coin', 'active' => '1' } } } + assert_equal 'Litecoin', row.refresh.name # bad scheme: nothing saved + get '/admin/invoices/payout-addresses' + assert_includes last_response.body, "scheme must look like" + + post '/admin/invoices/payout-addresses', { authenticity_token: token, + payout: { row.id.to_s => { 'coin' => 'LTC', 'name' => 'Main', 'address' => row.address, 'scheme' => 'litecoin', 'amount_param' => 'amount' } } } # active unticked + row.refresh + assert_equal 'Main', row.name + refute row.active + get '/admin/invoices/new' + refute_includes last_response.body, 'Main · LTC' # retired: not offered + + post "/admin/invoices/payout-addresses/#{row.id}/delete", { authenticity_token: token } + assert_nil PayoutAddress[row.id] + assert_includes DB.connection[:audit_log].select_map(:action), 'invoice.payout_address_delete' + end + + def test_rate_endpoint_uses_payout_coingecko_id + admin_login! + add('coin' => 'XYZ', 'address' => 'XYZaddress1234567') # custom coin, no CoinGecko id + get "/admin/crypto-rate?payout=#{PayoutAddress.first.id}¤cy=EUR" + assert_equal 502, last_response.status + assert_includes last_response.body, 'enter the rate by hand' end def test_read_only_user_sees_disabled_form @@ -53,9 +92,9 @@ class PayoutAddressTest < Minitest::Test admin_login!(username: 'reader', password: 'reader-password1') get '/admin/invoices/payout-addresses' assert last_response.ok? - assert_includes last_response.body, 'disabled' - refute_includes last_response.body, 'Save addresses' - post '/admin/invoices/payout-addresses', { authenticity_token: token, address: { 'LTC' => 'ltc1qbbbbbbbbbbbbbbbb' } } + refute_includes last_response.body, '>Save' + add('coin' => 'LTC', 'address' => 'ltc1qbbbbbbbbbbbbbbbb') assert_equal 403, last_response.status end + end diff --git a/views/admin/invoices/new.erb b/views/admin/invoices/new.erb index f169575..75f905d 100644 --- a/views/admin/invoices/new.erb +++ b/views/admin/invoices/new.erb @@ -84,24 +84,19 @@

Crypto payment (optional)

-

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

-
-
- - -
-
- - -
+

Choose a payout address to add a crypto block and QR to the invoice.

+ <% payouts = PayoutAddress.active_list %> +
+ + + <% if @form_errors && @form_errors[:payout_id] %><%= @form_errors[:payout_id] %><% end %>
-
+
>
- diff --git a/views/admin/invoices/payout_addresses.erb b/views/admin/invoices/payout_addresses.erb index 4a2085c..57213ce 100644 --- a/views/admin/invoices/payout_addresses.erb +++ b/views/admin/invoices/payout_addresses.erb @@ -1,30 +1,72 @@ +<% writable = can?('invoices', 'write') %> <%== erb :'admin/_nav', layout: false %>

Payout addresses

-

← invoices · default address per coin, prefilled into new invoices

+

← invoices · crypto payment options for new invoices

<% if @flash %>
<%= @flash %>
<% end %> +<% CryptoAsset::KNOWN_SCHEMES.each do |sch| %><% end %> +<% CryptoAsset::DEFAULTS.each do |code, a| %><% end %> +
-
> - - - - <% CryptoAsset::CODES.each do |coin| %> - <% row = @addresses[coin] %> - - - - - +
> + <% rows = @rows.map { |r| [r.id.to_s, r] } %> + <% rows << ['new', nil] if writable %> + <% rows.each do |key, r| %> +
+
+ <%= r ? "#{r.name} · #{r.coin}" : 'Add a payout address' %> + <% if r %> + + <% else %> + known tickers fill the rest automatically + <% end %> +
+
+
+
/>
+
+
+ + +
+
+
/>
+
+ <% if r %> +

QR → <%= CryptoQr.uri(scheme: r.scheme, amount_param: r.amount_param, address: r.address, amount: BigDecimal('0.5')) %> + · updated <%= r.updated_at.strftime('%Y-%m-%d') %> by <%= r.updated_by %>

<% end %> -
-
CoinAddressUpdated
<%= coin %>
<%= CryptoAsset.name(coin) %>
<%= row ? "#{row.updated_at.strftime('%Y-%m-%d')} · #{row.updated_by}" : '—' %>
-

Leave a field empty to remove that default. Existing invoices keep the address captured when they were issued.

- <% if can?('invoices', 'write') %><% end %> +
+ <% end %> +
    +
  • QR scheme is the full coin name wallets recognise, without the colon: litecoin, not ltc. Leave it empty for tokens (USDT, USDC…): the QR then holds the bare address and the PDF tells the payer to check the network.
  • +
  • Amount in QR: amount for Bitcoin-style coins (BTC, LTC, SOL…), tx_amount for Monero; needs a scheme.
  • +
  • CoinGecko id enables "Fetch live" on the invoice form; it's the slug in the coin's coingecko.com URL.
  • +
  • Invoices copy these fields when issued, so editing, retiring or removing an entry never changes existing invoices.
  • +
+ <% if writable %><% end %> + +<% if can?('invoices', 'delete') && @rows.any? %> +
+

Remove

+
+ <% @rows.each do |r| %> +
+ + +
+ <% end %> +
+
+<% end %> diff --git a/views/admin/invoices/stats.erb b/views/admin/invoices/stats.erb index 11de5c9..16f2a60 100644 --- a/views/admin/invoices/stats.erb +++ b/views/admin/invoices/stats.erb @@ -88,8 +88,8 @@ - <% s.crypto_usage.each do |coin, n| %> - + <% s.crypto_usage.each do |coin, name, n| %> + <% end %>
AssetInvoices
<%= coin %> <%= CryptoAsset.name(coin) %><%= n %>
<%= coin %> <%= name %><%= n %>