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 ### 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 ### PDF rendering

View file

@ -182,17 +182,18 @@ class AsxpioWeb < Sinatra::Base
erb :'admin/invoices/index' erb :'admin/invoices/index'
end end
# Live price of the selected crypto asset for the new-invoice form's # Live price for the new-invoice form's "Fetch live" button, using the
# "Fetch live" button. GEL is derived from the gel_rate the operator typed # chosen payout address's CoinGecko id. GEL is derived from the gel_rate
# into the form. # the operator typed into the form.
get '/admin/crypto-rate' do get '/admin/crypto-rate' do
content_type :json content_type :json
coin = params[:coin].to_s.upcase payout = PayoutAddress[params[:payout].to_i]
currency = params[:currency].to_s.upcase currency = params[:currency].to_s.upcase
gel_rate = params[:gel_rate].to_s.strip gel_rate = params[:gel_rate].to_s.strip
halt 404, { error: 'Unknown payout address' }.to_json unless payout
begin begin
rate = CryptoRate.fetch(coin, currency, gel_rate: gel_rate.empty? ? nil : gel_rate) rate = CryptoRate.fetch(payout.coingecko, currency, gel_rate: gel_rate.empty? ? nil : gel_rate)
{ rate: rate.round(8).to_s('F'), coin: coin, currency: currency }.to_json { rate: rate.round(8).to_s('F'), coin: payout.coin, currency: currency }.to_json
rescue CryptoRate::Error => e rescue CryptoRate::Error => e
status 502 status 502
{ error: e.message }.to_json { error: e.message }.to_json
@ -235,20 +236,43 @@ class AsxpioWeb < Sinatra::Base
get '/admin/invoices/payout-addresses' do get '/admin/invoices/payout-addresses' do
@page_title = 'Payout addresses · admin' @page_title = 'Payout addresses · admin'
@noindex = true @noindex = true
@addresses = PayoutAddress.as_hash(:coin) @rows = PayoutAddress.listing
@flash = session.delete(:payout_flash) @flash = session.delete(:payout_flash)
erb :'admin/invoices/payout_addresses' erb :'admin/invoices/payout_addresses'
end 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 post '/admin/invoices/payout-addresses' do
before = PayoutAddress.map errors, changed = [], []
errors = CryptoAsset::CODES.filter_map do |coin| (params['payout'] || {}).each do |id, raw|
next unless params.dig('address', coin) fresh = id == 'new'
PayoutAddress.set!(coin, params['address'][coin], by: current_user.username) 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 end
changed = (before.keys | PayoutAddress.map.keys).reject { |c| before[c] == PayoutAddress.map[c] } audit!('invoice.payout_addresses', nil, changed.join('; ')) if changed.any?
audit!('invoice.payout_addresses', changed.join(', '), nil) if changed.any? session[:payout_flash] = errors.any? ? errors.join('; ') : (changed.any? ? 'Saved.' : 'Nothing changed.')
session[:payout_flash] = errors.any? ? errors.join('; ') : "Saved#{" (#{changed.join(', ')})" if changed.any?}." 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' redirect '/admin/invoices/payout-addresses'
end end
@ -280,8 +304,7 @@ class AsxpioWeb < Sinatra::Base
issued_on: Date.today.strftime('%Y-%m-%d'), issued_on: Date.today.strftime('%Y-%m-%d'),
due_on: (Date.today + term).strftime('%Y-%m-%d'), due_on: (Date.today + term).strftime('%Y-%m-%d'),
notes: src.notes, notes: src.notes,
crypto_coin: src.crypto_coin, payout_id: PayoutAddress.first(coin: src.crypto_coin, address: src.crypto_address, active: true)&.id.to_s,
crypto_address: src.crypto_address,
items: src.items_array items: src.items_array
} }
erb :'admin/invoices/new' erb :'admin/invoices/new'
@ -297,8 +320,7 @@ class AsxpioWeb < Sinatra::Base
issued_on: params[:issued_on].to_s.strip, issued_on: params[:issued_on].to_s.strip,
due_on: params[:due_on].to_s.strip, due_on: params[:due_on].to_s.strip,
notes: params[:notes].to_s.strip, notes: params[:notes].to_s.strip,
crypto_coin: params[:crypto_coin].to_s.upcase, payout_id: params[:payout_id].to_s.strip,
crypto_address: params[:crypto_address].to_s.strip,
crypto_rate: params[:crypto_rate].to_s.strip, crypto_rate: params[:crypto_rate].to_s.strip,
crypto_amount: params[:crypto_amount].to_s.strip, crypto_amount: params[:crypto_amount].to_s.strip,
items: (params[:items] || {}).values 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. # 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 # A losing attempt orphans its two S3 objects; harmless and near-impossible
# with a single operator. # with a single operator.
build_params = @form_values.merge(@payout ? @payout.snapshot : {})
attempts = 0 attempts = 0
begin begin
invoice = Invoice.build(@form_values) invoice = Invoice.build(build_params)
# Base key (no suffix); the two status variants get -pending/-paid appended. # Base key (no suffix); the two status variants get -pending/-paid appended.
invoice.pdf_key = "invoices/#{invoice.number}-#{invoice.uuid}.pdf" invoice.pdf_key = "invoices/#{invoice.number}-#{invoice.uuid}.pdf"
%w[pending paid].each do |st| %w[pending paid].each do |st|
@ -441,11 +465,12 @@ class AsxpioWeb < Sinatra::Base
end end
v[:items] = items v[:items] = items
# Crypto is optional; validate only when an address is present. # Crypto is optional: a chosen payout address (validated when it was
unless v[:crypto_address].to_s.strip.empty? # saved) supplies ticker, address and QR metadata.
errors[:crypto_coin] = 'Unknown coin' unless CryptoAsset.valid?(v[:crypto_coin]) @payout = nil
# Loose shape check: every supported chain's addresses are alphanumeric. unless v[:payout_id].to_s.empty?
errors[:crypto_address] = 'Address looks invalid' unless v[:crypto_address] =~ /\A[a-zA-Z0-9]{10,128}\z/ @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| %i[crypto_rate crypto_amount].each do |k|
next if v[k].to_s.strip.empty? next if v[k].to_s.strip.empty?
begin 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' require 'json'
# Registry of crypto payment assets — one asset (coin, or token+chain for # Built-in crypto templates. Payout addresses (payout_addresses table) carry
# stablecoins) per invoice. Adding an asset is one entry here plus, optionally, # their own ticker, name, QR scheme, amount parameter and CoinGecko id; these
# a default payout address in the CRYPTO_ADDRESSES env (JSON, code => address). # 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: # QR encoding is driven by scheme/amount_param:
# scheme + amount_param => scheme:<addr>?<param>=<amt> (wallet prefills amount) # 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 # coingecko is the id for the simple/price endpoint; chain variants of a
# stablecoin share the token's id. # stablecoin share the token's id.
module CryptoAsset module CryptoAsset
ASSETS = { DEFAULTS = {
'BTC' => { name: 'Bitcoin', coingecko: 'bitcoin', scheme: 'bitcoin', amount_param: 'amount' }, 'BTC' => { name: 'Bitcoin', coingecko: 'bitcoin', scheme: 'bitcoin', amount_param: 'amount' },
'LTC' => { name: 'Litecoin', coingecko: 'litecoin', scheme: 'litecoin', amount_param: 'amount' }, 'LTC' => { name: 'Litecoin', coingecko: 'litecoin', scheme: 'litecoin', amount_param: 'amount' },
'ETH' => { name: 'Ethereum', coingecko: 'ethereum', scheme: 'ethereum', amount_param: nil }, '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 } 'USDC-ALGO' => { name: 'USD Coin (Algorand)', coingecko: 'usd-coin', scheme: nil, amount_param: nil }
}.freeze }.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 module_function

View file

@ -2,25 +2,24 @@ require 'rqrcode'
require_relative 'crypto_asset' require_relative 'crypto_asset'
# Renders a crypto payment QR as PNG bytes for embedding in the PDF. # 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 # The payload follows the payout entry's scheme/amount parameter: a payment
# with amount, a bare scheme URI, or just the address. # URI with amount, a bare scheme URI, or just the address.
module CryptoQr module CryptoQr
module_function module_function
# `amount` is a BigDecimal or nil (address-only QR). # `amount` is a BigDecimal or nil (address-only QR).
def uri(coin, address, amount) def uri(scheme:, amount_param:, address:, amount:)
asset = CryptoAsset[coin] or return address return address unless scheme
return address unless asset[:scheme] base = "#{scheme}:#{address}"
base = "#{asset[:scheme]}:#{address}" return base if amount.nil? || amount_param.nil?
return base if amount.nil? || asset[:amount_param].nil?
# Trim trailing zeros so the wallet shows a clean amount; keep up to 8 dp. # Trim trailing zeros so the wallet shows a clean amount; keep up to 8 dp.
amt = amount.round(8).to_s('F').sub(/\.?0+$/, '') amt = amount.round(8).to_s('F').sub(/\.?0+$/, '')
"#{base}?#{asset[:amount_param]}=#{amt}" "#{base}?#{amount_param}=#{amt}"
end end
# Returns PNG bytes (binary string), sized for a crisp ~120pt PDF placement. # Returns PNG bytes (binary string), sized for a crisp ~120pt PDF placement.
def png(coin, address, amount, size: 480) def png(size: 480, **payload)
qr = RQRCode::QRCode.new(uri(coin, address, amount), level: :m) qr = RQRCode::QRCode.new(uri(**payload), level: :m)
qr.as_png( qr.as_png(
bit_depth: 1, bit_depth: 1,
border_modules: 2, border_modules: 2,

View file

@ -2,7 +2,6 @@ require 'net/http'
require 'json' require 'json'
require 'uri' require 'uri'
require 'bigdecimal' require 'bigdecimal'
require_relative 'crypto_asset'
# Fetches the current price of a CryptoAsset from CoinGecko (free, no API key). # Fetches the current price of a CryptoAsset from CoinGecko (free, no API key).
# #
@ -17,11 +16,11 @@ module CryptoRate
module_function 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'. # `gel_rate` (USD->GEL) is required only when currency == 'GEL'.
def fetch(coin, currency, gel_rate: nil) def fetch(id, currency, gel_rate: nil)
asset = CryptoAsset[coin] or raise Error, "Unknown coin: #{coin}" raise Error, 'No CoinGecko id set for this payout address; enter the rate by hand.' if id.to_s.empty?
id = asset[:coingecko]
currency = currency.to_s.upcase currency = currency.to_s.upcase
if currency == 'GEL' if currency == 'GEL'

View file

@ -103,7 +103,10 @@ class Invoice < Sequel::Model(:invoices)
private 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, # amount are both optional; if amount is blank but rate is present,
# derive it. # derive it.
def apply_crypto(invoice, params, subtotal) def apply_crypto(invoice, params, subtotal)
@ -111,10 +114,15 @@ class Invoice < Sequel::Model(:invoices)
return if address.empty? return if address.empty?
coin = params[:crypto_coin].to_s.upcase 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_coin = coin
invoice.crypto_address = address 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]) rate = decimal_or_nil(params[:crypto_rate])
invoice.crypto_rate = rate invoice.crypto_rate = rate

View file

@ -289,20 +289,21 @@ class InvoicePdf
def draw_crypto(pdf) def draw_crypto(pdf)
coin = @invoice.crypto_coin coin = @invoice.crypto_coin
asset = CryptoAsset[coin] || {} name = @invoice.crypto_name || CryptoAsset.name(coin) # snapshot; legacy fallback
amount = @invoice.crypto_amount_due amount = @invoice.crypto_amount_due
qr_size = 78 qr_size = 78
pdf.move_down 12 pdf.move_down 12
pdf.fill_color COLOR_MUTED 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.fill_color COLOR_TEXT
pdf.move_down 4 pdf.move_down 4
# Fixed-height row: QR on the right (drawn with float so it doesn't advance # 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 # 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_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.bounding_box([0, pdf.cursor], width: pdf.bounds.width, height: qr_size) do
pdf.float do pdf.float do
pdf.bounding_box([pdf.bounds.right - qr_size, pdf.bounds.top], width: qr_size, height: qr_size) 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.font_size(8) { pdf.text @invoice.crypto_address }
pdf.move_down 3 pdf.move_down 3
pdf.fill_color COLOR_MUTED pdf.fill_color COLOR_MUTED
hint = if asset[:amount_param] hint = if @invoice.crypto_scheme && @invoice.crypto_amount_param
"Scan the QR with any #{CryptoAsset.name(coin)} wallet to prefill the payment." "Scan the QR with any #{name} wallet to prefill the payment."
else else
"QR encodes the address. Send #{coin} only. Verify the network before sending." "QR encodes the address. Send #{coin} only. Verify the network before sending."
end 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) live.group_by(&:client_name).map { |c, l| [c, l.size, gel(l)] }.sort_by { |_, _, g| -g }.first(n)
end end
# [[ticker, name, invoices]] — name from the invoices' own snapshot.
def crypto_usage 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
end end

View file

@ -1,36 +1,71 @@
require 'sequel' require 'sequel'
require_relative 'crypto_asset' 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) class PayoutAddress < Sequel::Model(:payout_addresses)
unrestrict_primary_key ADDRESS_RE = /\A[a-zA-Z0-9]{10,128}\z/ # loose shape check, same as invoices
ADDRESS_RE = /\A[a-zA-Z0-9]{10,128}\z/ # same loose shape check as invoices FIELDS = %i[name coin scheme amount_param coingecko address].freeze
def self.map def self.listing
to_hash(:coin, :address).slice(*CryptoAsset::CODES) order(Sequel.desc(:active), :coin, :name, :id).all
end 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! def self.seed_from_env!
return if count.positive? return if count.positive?
CryptoAsset.env_addresses.each do |coin, address| CryptoAsset.env_addresses.each do |coin, address|
next unless address.match?(ADDRESS_RE) 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
end end
# Upsert or (blank address) delete. Returns an error string or nil. # Known ticker → its usual name/scheme/amount param/CoinGecko id.
def self.set!(coin, address, by:) def self.template(coin)
coin = coin.to_s.upcase a = CryptoAsset::DEFAULTS[coin.to_s.upcase] or return {}
return 'unknown coin' unless CryptoAsset.valid?(coin) { name: a[:name], scheme: a[:scheme], amount_param: a[:amount_param], coingecko: a[:coingecko] }
address = address.to_s.strip end
if address.empty?
where(coin: coin).delete # Cleans form input: blanks → nil, scheme lowercased without a trailing
return nil # 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 end
return "#{coin}: address looks invalid" unless address.match?(ADDRESS_RE) v[:name] ||= v[:coin]
row = self[coin] || new(coin: coin) v
row.set(address: address, updated_by: by, updated_at: Time.now.utc).save 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 nil
end 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 end

View file

@ -36,9 +36,8 @@
reindex(); reindex();
}); });
// --- Crypto: coin select, live rate fetch + auto amount ------------- // --- Crypto: payout address, live rate fetch + auto amount ----------
const coinSel = document.getElementById('crypto_coin'); const payoutSel = document.getElementById('payout_id');
const addressInput = document.getElementById('crypto_address');
const rateInput = document.getElementById('crypto_rate'); const rateInput = document.getElementById('crypto_rate');
const amountInput = document.getElementById('crypto_amount'); const amountInput = document.getElementById('crypto_amount');
const fetchBtn = document.getElementById('fetch-crypto-rate'); const fetchBtn = document.getElementById('fetch-crypto-rate');
@ -48,20 +47,13 @@
const currencySel = document.getElementById('currency'); const currencySel = document.getElementById('currency');
const gelRateInput = document.getElementById('gel_rate'); const gelRateInput = document.getElementById('gel_rate');
// Default payout addresses per coin, rendered server-side from env. function selectedPayout() {
let cryptoDefaults = {}; return payoutSel ? payoutSel.selectedOptions[0] : null;
const defaultsEl = document.getElementById('crypto-defaults');
if (defaultsEl) {
try { cryptoDefaults = JSON.parse(defaultsEl.textContent); } catch (e) { /* leave empty */ }
} }
// On coin change: swap in the new coin's default address, but never // A different payout address means a different coin: stale rate/amount out.
// clobber a hand-entered one (only replace blank or known defaults). if (payoutSel) {
if (coinSel && addressInput) { payoutSel.addEventListener('change', function () {
coinSel.addEventListener('change', function () {
const val = addressInput.value.trim();
const isDefault = val === '' || Object.values(cryptoDefaults).indexOf(val) !== -1;
if (isDefault) addressInput.value = cryptoDefaults[coinSel.value] || '';
rateInput.value = ''; rateInput.value = '';
if (amountInput) amountInput.value = ''; if (amountInput) amountInput.value = '';
amountTouched = false; amountTouched = false;
@ -99,7 +91,11 @@
const ccy = currencySel ? currencySel.value : 'USD'; const ccy = currencySel ? currencySel.value : 'USD';
if (rateCcy) rateCcy.textContent = ccy; if (rateCcy) rateCcy.textContent = ccy;
if (gelRateCcy) gelRateCcy.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(); syncCcyLabel();
if (currencySel) currencySel.addEventListener('change', syncCcyLabel); if (currencySel) currencySel.addEventListener('change', syncCcyLabel);
@ -110,8 +106,9 @@
if (fetchBtn) { if (fetchBtn) {
fetchBtn.addEventListener('click', function () { fetchBtn.addEventListener('click', function () {
const ccy = currencySel ? currencySel.value : 'USD'; const ccy = currencySel ? currencySel.value : 'USD';
const coin = coinSel ? coinSel.value : 'LTC'; const opt = selectedPayout();
const params = new URLSearchParams({ coin: coin, currency: ccy }); 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); if (gelRateInput && gelRateInput.value) params.set('gel_rate', gelRateInput.value);
rateStatus.textContent = ' fetching…'; rateStatus.textContent = ' fetching…';
fetch('/admin/crypto-rate?' + params.toString(), { headers: { Accept: 'application/json' } }) 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 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; } .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 */ /* Invoice statistics */
.year-filter { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-bottom: 24px; } .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; } .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: '', client_name: 'ACME', client_email: 'billing@example.com', client_address: '',
currency: 'EUR', gel_rate: '3.05', currency: 'EUR', gel_rate: '3.05',
issued_on: Date.today.to_s, due_on: (Date.today + 14).to_s, notes: '', 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' } } } items: { '0' => { 'description' => 'Engineering — test', 'qty' => '2', 'unit_price' => '100.50' } } }
end end
@ -94,16 +94,22 @@ class AppTest < Minitest::Test
skip 'TEST_DATABASE_URL not set' unless TestDb.available? skip 'TEST_DATABASE_URL not set' unless TestDb.available?
token = csrf_token_from('/admin/invoices/new', admin_env) token = csrf_token_from('/admin/invoices/new', admin_env)
params = invoice_form_params(token).merge( payout = PayoutAddress.create(coin: 'USDT-TRC20', name: 'Tether (Tron)', address: 'TXYZexampleexampleexampleexample12',
crypto_coin: 'USDT-TRC20', crypto_address: 'TXYZexampleexampleexampleexample12', updated_by: 't', updated_at: Time.now.utc)
crypto_rate: '1.00', crypto_amount: '' params = invoice_form_params(token).merge(payout_id: payout.id.to_s, crypto_rate: '1.00', crypto_amount: '')
)
post '/admin/invoices', params, admin_env post '/admin/invoices', params, admin_env
assert_equal 302, last_response.status, last_response.body assert_equal 302, last_response.status, last_response.body
invoice = Invoice.first invoice = Invoice.first
assert_equal 'USDT-TRC20', invoice.crypto_coin 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 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 end
def test_create_invoice_validation_failure_rerenders_form def test_create_invoice_validation_failure_rerenders_form

View file

@ -43,29 +43,30 @@ class CryptoAssetTest < Minitest::Test
end end
class CryptoQrTest < Minitest::Test 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 def test_bip21_uri_with_amount
assert_equal 'bitcoin:bc1qaddr?amount=0.005', assert_equal 'bitcoin:bc1qaddr?amount=0.005', uri('bitcoin', 'amount', 'bc1qaddr', BigDecimal('0.00500000'))
CryptoQr.uri('BTC', 'bc1qaddr', BigDecimal('0.00500000')) assert_equal 'litecoin:ltc1qaddr?amount=1.25', uri('litecoin', 'amount', 'ltc1qaddr', BigDecimal('1.25'))
assert_equal 'litecoin:ltc1qaddr?amount=1.25',
CryptoQr.uri('LTC', 'ltc1qaddr', BigDecimal('1.25'))
end end
def test_monero_uses_tx_amount def test_monero_uses_tx_amount
assert_equal 'monero:4Aaddr?tx_amount=2.5', assert_equal 'monero:4Aaddr?tx_amount=2.5', uri('monero', 'tx_amount', '4Aaddr', BigDecimal('2.5'))
CryptoQr.uri('XMR', '4Aaddr', BigDecimal('2.5'))
end end
def test_eth_scheme_without_amount def test_scheme_without_amount_param
assert_equal 'ethereum:0xAddr', CryptoQr.uri('ETH', '0xAddr', BigDecimal('1.5')) assert_equal 'ethereum:0xAddr', uri('ethereum', nil, '0xAddr', BigDecimal('1.5'))
end end
def test_tokens_encode_bare_address def test_no_scheme_encodes_bare_address
assert_equal 'TAddr', CryptoQr.uri('USDT-TRC20', 'TAddr', BigDecimal('100')) assert_equal 'TAddr', uri(nil, nil, 'TAddr', BigDecimal('100'))
assert_equal 'AAddr', CryptoQr.uri('USDC-ALGO', 'AAddr', nil) assert_equal 'AAddr', uri(nil, nil, 'AAddr', nil)
end end
def test_png_renders 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) assert png.start_with?("\x89PNG".b)
end end
end end

View file

@ -71,14 +71,32 @@ class InvoiceTest < Minitest::Test
assert_equal BigDecimal('1.25'), inv.crypto_amount_due assert_equal BigDecimal('1.25'), inv.crypto_amount_due
end 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 assert_raises(ArgumentError) do
Invoice.build( Invoice.build(client_name: 'A', client_email: 'a@example.com', currency: 'EUR', gel_rate: '3',
client_name: 'ACME', client_email: 'billing@example.com', items: [{ 'description' => 'w', 'qty' => '1', 'unit_price' => '1' }],
currency: 'EUR', gel_rate: '3.05', crypto_coin: 'bad coin', crypto_address: 'D6examplexampleexampleexample')
items: [{ 'description' => 'work', 'qty' => '1', 'unit_price' => '100' }],
crypto_coin: 'DOGE', crypto_address: 'D6examplexampleexampleexample'
)
end end
end end
end end
@ -86,7 +104,7 @@ end
class InvoiceValidationTest < Minitest::Test class InvoiceValidationTest < Minitest::Test
BASE = { BASE = {
client_name: 'ACME', client_email: 'billing@example.com', currency: 'EUR', 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' }] items: [{ 'description' => 'work', 'qty' => '1', 'unit_price' => '100' }]
}.freeze }.freeze
@ -122,17 +140,21 @@ class InvoiceValidationTest < Minitest::Test
assert_includes validate(items: [{ 'description' => '', 'qty' => '1', 'unit_price' => '1' }]), :items assert_includes validate(items: [{ 'description' => '', 'qty' => '1', 'unit_price' => '1' }]), :items
end end
def test_crypto_address_format def test_unknown_or_inactive_payout_rejected
assert_includes validate(crypto_address: 'not!an@address'), :crypto_address 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 end
def test_crypto_unknown_coin_rejected def test_crypto_rate_must_be_positive
v = validate(crypto_coin: 'DOGE', crypto_address: 'D6exampleexampleexampleexample') p = PayoutAddress.create(coin: 'LTC', name: 'Litecoin', address: 'ltc1qvalidvalidvalidvalid', updated_by: 't', updated_at: Time.now.utc)
assert_includes v, :crypto_coin assert_includes validate(payout_id: p.id.to_s, crypto_rate: '-1'), :crypto_rate
end ensure
p&.destroy
def test_crypto_valid_asset_passes
v = validate(crypto_coin: 'USDT-TRC20', crypto_address: 'TXYZexampleexampleexampleexample12')
assert_empty v
end end
end end

View file

@ -14,37 +14,76 @@ class PayoutAddressTest < Minitest::Test
DB.connection[:payout_addresses].delete DB.connection[:payout_addresses].delete
end end
def token def token(path = '/admin/invoices/payout-addresses')
get '/admin/invoices/payout-addresses' get path
last_response.body[/name="authenticity_token" value="([^"]+)"/, 1] last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
end end
def add(fields)
post '/admin/invoices/payout-addresses', { authenticity_token: token, payout: { 'new' => fields } }
end
def test_seed_from_env_once def test_seed_from_env_once
ENV['CRYPTO_ADDRESSES'] = '{"BTC":"bc1qseedseedseedseed","XYZ":"nope00000000"}' ENV['CRYPTO_ADDRESSES'] = '{"BTC":"bc1qseedseedseedseed","XYZ":"nope00000000"}'
PayoutAddress.seed_from_env! 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"}' ENV['CRYPTO_ADDRESSES'] = '{"BTC":"bc1qotherotherother"}'
PayoutAddress.seed_from_env! PayoutAddress.seed_from_env!
assert_equal 'bc1qseedseedseedseed', PayoutAddress.map['BTC'] # not re-seeded assert_equal 1, PayoutAddress.count
ensure ensure
ENV.delete('CRYPTO_ADDRESSES') ENV.delete('CRYPTO_ADDRESSES')
end end
def test_admin_edits_validates_and_removes def test_add_known_ticker_fills_from_template_and_custom_coin
admin_login! admin_login!
post '/admin/invoices/payout-addresses', { authenticity_token: token, address: { 'LTC' => 'ltc1qaaaaaaaaaaaaaaaa', 'BTC' => 'bad addr!' } } add('coin' => 'ltc', 'address' => 'ltc1qaaaaaaaaaaaaaaaa', 'name' => 'Litecoin (Ledger)')
assert_equal 302, last_response.status add('coin' => 'LTC', 'address' => 'ltc1qbbbbbbbbbbbbbbbb', 'name' => 'Litecoin (Exchange)')
assert_equal 'ltc1qaaaaaaaaaaaaaaaa', PayoutAddress.map['LTC'] add('coin' => 'DOGE', 'address' => 'DAddr1234567890', 'name' => 'Dogecoin', 'scheme' => 'dogecoin:', 'amount_param' => 'amount', 'coingecko' => 'dogecoin')
assert_nil PayoutAddress.map['BTC'] 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' get '/admin/invoices/payout-addresses'
assert_includes last_response.body, 'BTC: address looks invalid' 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' 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' => '' } } def test_edit_validate_retire_and_remove
assert_nil PayoutAddress.map['LTC'] 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 end
def test_read_only_user_sees_disabled_form def test_read_only_user_sees_disabled_form
@ -53,9 +92,9 @@ class PayoutAddressTest < Minitest::Test
admin_login!(username: 'reader', password: 'reader-password1') admin_login!(username: 'reader', password: 'reader-password1')
get '/admin/invoices/payout-addresses' get '/admin/invoices/payout-addresses'
assert last_response.ok? assert last_response.ok?
assert_includes last_response.body, 'disabled' refute_includes last_response.body, '>Save</button>'
refute_includes last_response.body, 'Save addresses' add('coin' => 'LTC', 'address' => 'ltc1qbbbbbbbbbbbbbbbb')
post '/admin/invoices/payout-addresses', { authenticity_token: token, address: { 'LTC' => 'ltc1qbbbbbbbbbbbbbbbb' } }
assert_equal 403, last_response.status assert_equal 403, last_response.status
end end
end end

View file

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

View file

@ -1,30 +1,72 @@
<% writable = can?('invoices', 'write') %>
<%== erb :'admin/_nav', layout: false %> <%== erb :'admin/_nav', layout: false %>
<div class="header"> <div class="header">
<h1>Payout addresses</h1> <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> </div>
<% if @flash %><div class="form-error-base"><div><%= @flash %></div></div><% end %> <% 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"> <section class="section">
<form class="invoice-form" method="post" action="/admin/invoices/payout-addresses" autocomplete="off"> <form class="invoice-form" method="post" action="/admin/invoices/payout-addresses" autocomplete="off">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" /> <input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<fieldset class="plain" <%= 'disabled' unless can?('invoices', 'write') %>> <fieldset class="plain" <%= 'disabled' unless writable %>>
<table class="invoice-list"> <% rows = @rows.map { |r| [r.id.to_s, r] } %>
<thead><tr><th>Coin</th><th>Address</th><th>Updated</th></tr></thead> <% rows << ['new', nil] if writable %>
<tbody> <% rows.each do |key, r| %>
<% CryptoAsset::CODES.each do |coin| %> <div class="payout-card<%= ' inactive' if r && !r.active %><%= ' new' unless r %>">
<% row = @addresses[coin] %> <div class="payout-head">
<tr> <b><%= r ? "#{r.name} · #{r.coin}" : 'Add a payout address' %></b>
<td><b class="mono"><%= coin %></b><br /><span class="optional"><%= CryptoAsset.name(coin) %></span></td> <% if r %>
<td><input type="text" name="address[<%= coin %>]" value="<%= row&.address %>" class="mono" placeholder="not set" maxlength="128" spellcheck="false" /></td> <label><input type="checkbox" name="payout[<%= key %>][active]" value="1" <%= 'checked' if r.active %> /> active</label>
<td class="optional"><%= row ? "#{row.updated_at.strftime('%Y-%m-%d')} · #{row.updated_by}" : '—' %></td> <% else %>
</tr> <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 %> <% end %>
</tbody> </div>
</table> <% end %>
<p class="optional">Leave a field empty to remove that default. Existing invoices keep the address captured when they were issued.</p> <ul class="optional">
<% if can?('invoices', 'write') %><button type="submit" class="download-button">Save addresses</button><% end %> <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> </fieldset>
</form> </form>
</section> </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"> <table class="invoice-list">
<thead><tr><th>Asset</th><th class="num">Invoices</th></tr></thead> <thead><tr><th>Asset</th><th class="num">Invoices</th></tr></thead>
<tbody> <tbody>
<% s.crypto_usage.each do |coin, n| %> <% s.crypto_usage.each do |coin, name, n| %>
<tr><td><span class="mono"><%= coin %></span> <span class="optional"><%= CryptoAsset.name(coin) %></span></td><td class="num mono"><%= n %></td></tr> <tr><td><span class="mono"><%= coin %></span> <span class="optional"><%= name %></span></td><td class="num mono"><%= n %></td></tr>
<% end %> <% end %>
</tbody> </tbody>
</table> </table>