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
This commit is contained in:
Sergei Poljanski 2026-10-10 09:17:09 +04:00
commit 33ba7650c3
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
19 changed files with 420 additions and 176 deletions

View file

@ -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=&currency=&gel_rate=` returns the live price via `lib/crypto_rate.rb` (CoinGecko, no API key; USD/EUR direct, GEL derived from the form's `gel_rate`; chain variants of a stablecoin share the token's CoinGecko id). `lib/crypto_qr.rb` renders the QR for the PDF: payment URI with amount where a scheme supports it (BTC/LTC BIP21, XMR `tx_amount`, SOL Solana Pay), scheme-only for ETH, bare address for tokens/ALGO (the PDF prints a "verify the network" hint for those).
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=<id>&currency=&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

View file

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

View file

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

View file

@ -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:<addr>?<param>=<amt> (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

View file

@ -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,

View file

@ -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'

View file

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

View file

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

View file

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

View file

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

View file

@ -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' } })

View file

@ -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; }

View file

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

View file

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

View file

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

View file

@ -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}&currency=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</button>'
add('coin' => 'LTC', 'address' => 'ltc1qbbbbbbbbbbbbbbbb')
assert_equal 403, last_response.status
end
end

View file

@ -84,24 +84,19 @@
</div>
<h2>Crypto payment <span class="optional">(optional)</span></h2>
<p class="optional" style="margin-top:-6px;">Leave the address blank to omit the crypto block and QR from the invoice.</p>
<div class="invoice-grid">
<div class="field">
<label for="crypto_coin">Coin</label>
<select id="crypto_coin" name="crypto_coin">
<% CryptoAsset::CODES.each do |c| %>
<option value="<%= c %>" <%= 'selected' if (@form_values[:crypto_coin] || 'LTC') == c %>><%= c %> · <%= CryptoAsset.name(c) %></option>
<% end %>
</select>
</div>
<div class="field">
<label for="crypto_address">Address</label>
<input type="text" id="crypto_address" name="crypto_address"
value="<%= @form_values[:crypto_address] || PayoutAddress.map[@form_values[:crypto_coin] || 'LTC'] %>"
placeholder="payout address" maxlength="128" />
</div>
<p class="optional" style="margin-top:-6px;">Choose a payout address to add a crypto block and QR to the invoice.</p>
<% payouts = PayoutAddress.active_list %>
<div class="field">
<label for="payout_id">Payout address <span class="optional">(<a href="/admin/invoices/payout-addresses">manage</a>)</span></label>
<select id="payout_id" name="payout_id">
<option value="" data-coin="">— no crypto payment —</option>
<% payouts.each do |p| %>
<option value="<%= p.id %>" data-coin="<%= p.coin %>" data-rate="<%= p.coingecko ? '1' : '' %>" <%= 'selected' if @form_values[:payout_id].to_s == p.id.to_s %>><%= p.option_label %></option>
<% end %>
</select>
<% if @form_errors && @form_errors[:payout_id] %><span class="form-error"><%= @form_errors[:payout_id] %></span><% end %>
</div>
<div class="invoice-grid">
<div class="invoice-grid" id="crypto-fields" <%= 'hidden' if @form_values[:payout_id].to_s.empty? %>>
<div class="field">
<label for="crypto_rate">Rate (1 <span id="crypto-rate-coin">coin</span> in <span id="crypto-rate-ccy">currency</span>)</label>
<input type="text" id="crypto_rate" name="crypto_rate" value="<%= @form_values[:crypto_rate] %>"
@ -115,7 +110,6 @@
placeholder="auto" inputmode="decimal" />
</div>
</div>
<script type="application/json" id="crypto-defaults"><%== PayoutAddress.map.to_json %></script>
<button type="submit" class="download-button">Create invoice</button>
</form>

View file

@ -1,30 +1,72 @@
<% writable = can?('invoices', 'write') %>
<%== erb :'admin/_nav', layout: false %>
<div class="header">
<h1>Payout addresses</h1>
<p class="tagline"><a href="/admin/invoices">← invoices</a> · default address per coin, prefilled into new invoices</p>
<p class="tagline"><a href="/admin/invoices">← invoices</a> · crypto payment options for new invoices</p>
</div>
<% if @flash %><div class="form-error-base"><div><%= @flash %></div></div><% end %>
<datalist id="schemes"><% CryptoAsset::KNOWN_SCHEMES.each do |sch| %><option value="<%= sch %>"></option><% end %></datalist>
<datalist id="tickers"><% CryptoAsset::DEFAULTS.each do |code, a| %><option value="<%= code %>"><%= a[:name] %></option><% end %></datalist>
<section class="section">
<form class="invoice-form" method="post" action="/admin/invoices/payout-addresses" autocomplete="off">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<fieldset class="plain" <%= 'disabled' unless can?('invoices', 'write') %>>
<table class="invoice-list">
<thead><tr><th>Coin</th><th>Address</th><th>Updated</th></tr></thead>
<tbody>
<% CryptoAsset::CODES.each do |coin| %>
<% row = @addresses[coin] %>
<tr>
<td><b class="mono"><%= coin %></b><br /><span class="optional"><%= CryptoAsset.name(coin) %></span></td>
<td><input type="text" name="address[<%= coin %>]" value="<%= row&.address %>" class="mono" placeholder="not set" maxlength="128" spellcheck="false" /></td>
<td class="optional"><%= row ? "#{row.updated_at.strftime('%Y-%m-%d')} · #{row.updated_by}" : '—' %></td>
</tr>
<fieldset class="plain" <%= 'disabled' unless writable %>>
<% rows = @rows.map { |r| [r.id.to_s, r] } %>
<% rows << ['new', nil] if writable %>
<% rows.each do |key, r| %>
<div class="payout-card<%= ' inactive' if r && !r.active %><%= ' new' unless r %>">
<div class="payout-head">
<b><%= r ? "#{r.name} · #{r.coin}" : 'Add a payout address' %></b>
<% if r %>
<label><input type="checkbox" name="payout[<%= key %>][active]" value="1" <%= 'checked' if r.active %> /> active</label>
<% else %>
<span class="optional">known tickers fill the rest automatically</span>
<% end %>
</div>
<div class="payout-grid">
<div class="field"><label>Name</label><input type="text" name="payout[<%= key %>][name]" value="<%= r&.name %>" placeholder="Litecoin (Ledger)" maxlength="60" /></div>
<div class="field"><label>Ticker <span class="optional">(on the PDF)</span></label><input type="text" name="payout[<%= key %>][coin]" value="<%= r&.coin %>" list="tickers" placeholder="LTC" class="mono" maxlength="20" <%= 'required' if r %> /></div>
<div class="field"><label>QR scheme <span class="optional">(empty = bare address)</span></label><input type="text" name="payout[<%= key %>][scheme]" value="<%= r&.scheme %>" list="schemes" placeholder="<%= r ? 'none (bare address)' : 'litecoin' %>" class="mono" /></div>
<div class="field">
<label>Amount in QR</label>
<select name="payout[<%= key %>][amount_param]">
<option value="" <%= 'selected' unless r&.amount_param %>>no</option>
<% CryptoAsset::AMOUNT_PARAMS.each do |ap| %><option value="<%= ap %>" <%= 'selected' if r&.amount_param == ap %>><%= ap %></option><% end %>
</select>
</div>
<div class="field"><label>CoinGecko id <span class="optional">(empty = type rate)</span></label><input type="text" name="payout[<%= key %>][coingecko]" value="<%= r&.coingecko %>" placeholder="<%= r ? 'none (type the rate)' : 'litecoin' %>" class="mono" /></div>
<div class="field payout-address"><label>Address</label><input type="text" name="payout[<%= key %>][address]" value="<%= r&.address %>" placeholder="ltc1q…" class="mono" maxlength="128" spellcheck="false" <%= 'required' if r %> /></div>
</div>
<% if r %>
<p class="mono optional qr-line">QR → <%= CryptoQr.uri(scheme: r.scheme, amount_param: r.amount_param, address: r.address, amount: BigDecimal('0.5')) %>
<span class="payout-meta">· updated <%= r.updated_at.strftime('%Y-%m-%d') %> by <%= r.updated_by %></span></p>
<% end %>
</tbody>
</table>
<p class="optional">Leave a field empty to remove that default. Existing invoices keep the address captured when they were issued.</p>
<% if can?('invoices', 'write') %><button type="submit" class="download-button">Save addresses</button><% end %>
</div>
<% end %>
<ul class="optional">
<li><b>QR scheme</b> is the full coin name wallets recognise, without the colon: <span class="mono">litecoin</span>, not <span class="mono">ltc</span>. Leave it empty for tokens (USDT, USDC…): the QR then holds the bare address and the PDF tells the payer to check the network.</li>
<li><b>Amount in QR</b>: <span class="mono">amount</span> for Bitcoin-style coins (BTC, LTC, SOL…), <span class="mono">tx_amount</span> for Monero; needs a scheme.</li>
<li><b>CoinGecko id</b> enables "Fetch live" on the invoice form; it's the slug in the coin's coingecko.com URL.</li>
<li>Invoices copy these fields when issued, so editing, retiring or removing an entry never changes existing invoices.</li>
</ul>
<% if writable %><button type="submit" class="download-button">Save</button><% end %>
</fieldset>
</form>
</section>
<% if can?('invoices', 'delete') && @rows.any? %>
<section class="section">
<h2>Remove</h2>
<div class="invoice-actions">
<% @rows.each do |r| %>
<form method="post" action="/admin/invoices/payout-addresses/<%= r.id %>/delete" onsubmit="return confirm('Remove this payout address? Existing invoices keep their copy.')">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button danger"><%= r.coin %> · <%= r.name %></button>
</form>
<% end %>
</div>
</section>
<% end %>

View file

@ -88,8 +88,8 @@
<table class="invoice-list">
<thead><tr><th>Asset</th><th class="num">Invoices</th></tr></thead>
<tbody>
<% s.crypto_usage.each do |coin, n| %>
<tr><td><span class="mono"><%= coin %></span> <span class="optional"><%= CryptoAsset.name(coin) %></span></td><td class="num mono"><%= n %></td></tr>
<% s.crypto_usage.each do |coin, name, n| %>
<tr><td><span class="mono"><%= coin %></span> <span class="optional"><%= name %></span></td><td class="num mono"><%= n %></td></tr>
<% end %>
</tbody>
</table>