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 %>