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:
parent
78ea9926e7
commit
33ba7650c3
19 changed files with 420 additions and 176 deletions
|
|
@ -105,7 +105,7 @@ Public:
|
|||
|
||||
### Crypto payment
|
||||
|
||||
Opt-in per invoice, one asset per invoice. `lib/crypto_asset.rb` is the registry (BTC, LTC, ETH, XMR, SOL, ALGO, USDT/USDC across ERC-20/TRC-20/BEP-20/Solana/Algorand) — adding an asset is one entry there. The new-invoice form has a coin select plus address (prefilled per coin from the `payout_addresses` table, managed at `/admin/invoices/payout-addresses`; seeded once from the old `CRYPTO_ADDRESSES`/`LTC_ADDRESS` env by `PayoutAddress.seed_from_env!`), a rate field with a "Fetch live" button, and an editable amount overriding the derived value. `GET /admin/crypto-rate?coin=¤cy=&gel_rate=` returns the live price via `lib/crypto_rate.rb` (CoinGecko, no API key; USD/EUR direct, GEL derived from the form's `gel_rate`; chain variants of a stablecoin share the token's CoinGecko id). `lib/crypto_qr.rb` renders the QR for the PDF: payment URI with amount where a scheme supports it (BTC/LTC BIP21, XMR `tx_amount`, SOL Solana Pay), scheme-only for ETH, bare address for tokens/ALGO (the PDF prints a "verify the network" hint for those).
|
||||
Opt-in per invoice. Crypto options are **payout addresses** (`payout_addresses`, `lib/payout_address.rb`, edited at `/admin/invoices/payout-addresses`): each row is self-contained — ticker (`coin`, printed on the PDF), name, QR URI scheme (`litecoin`; NULL = bare address), amount parameter (`amount`/`tx_amount`/NULL), CoinGecko id (NULL = rate typed by hand), address, active. Several per ticker are fine; any coin can be added. `CryptoAsset::DEFAULTS` is only a template set (prefills those fields for a known ticker, backfilled migration 012, names legacy invoices) plus the one-time env seed (`PayoutAddress.seed_from_env!` from `CRYPTO_ADDRESSES`/`LTC_ADDRESS`). The new-invoice form picks a payout address; the invoice **snapshots** `crypto_coin`, `crypto_address`, `crypto_name`, `crypto_scheme`, `crypto_amount_param`, so editing, retiring or deleting a payout entry never changes issued invoices. The form has a "Fetch live" button: `GET /admin/crypto-rate?payout=<id>¤cy=&gel_rate=` returns the price via `lib/crypto_rate.rb` (CoinGecko, no API key; USD/EUR direct, GEL derived from the form's `gel_rate`). `lib/crypto_qr.rb` renders the QR from the invoice's snapshot: `scheme:address?param=amount`, scheme-only, or the bare address (the PDF then prints a "verify the network" hint).
|
||||
|
||||
### PDF rendering
|
||||
|
||||
|
|
|
|||
73
asxpio.rb
73
asxpio.rb
|
|
@ -182,17 +182,18 @@ class AsxpioWeb < Sinatra::Base
|
|||
erb :'admin/invoices/index'
|
||||
end
|
||||
|
||||
# Live price of the selected crypto asset for the new-invoice form's
|
||||
# "Fetch live" button. GEL is derived from the gel_rate the operator typed
|
||||
# into the form.
|
||||
# Live price for the new-invoice form's "Fetch live" button, using the
|
||||
# chosen payout address's CoinGecko id. GEL is derived from the gel_rate
|
||||
# the operator typed into the form.
|
||||
get '/admin/crypto-rate' do
|
||||
content_type :json
|
||||
coin = params[:coin].to_s.upcase
|
||||
payout = PayoutAddress[params[:payout].to_i]
|
||||
currency = params[:currency].to_s.upcase
|
||||
gel_rate = params[:gel_rate].to_s.strip
|
||||
halt 404, { error: 'Unknown payout address' }.to_json unless payout
|
||||
begin
|
||||
rate = CryptoRate.fetch(coin, currency, gel_rate: gel_rate.empty? ? nil : gel_rate)
|
||||
{ rate: rate.round(8).to_s('F'), coin: coin, currency: currency }.to_json
|
||||
rate = CryptoRate.fetch(payout.coingecko, currency, gel_rate: gel_rate.empty? ? nil : gel_rate)
|
||||
{ rate: rate.round(8).to_s('F'), coin: payout.coin, currency: currency }.to_json
|
||||
rescue CryptoRate::Error => e
|
||||
status 502
|
||||
{ error: e.message }.to_json
|
||||
|
|
@ -235,20 +236,43 @@ class AsxpioWeb < Sinatra::Base
|
|||
get '/admin/invoices/payout-addresses' do
|
||||
@page_title = 'Payout addresses · admin'
|
||||
@noindex = true
|
||||
@addresses = PayoutAddress.as_hash(:coin)
|
||||
@rows = PayoutAddress.listing
|
||||
@flash = session.delete(:payout_flash)
|
||||
erb :'admin/invoices/payout_addresses'
|
||||
end
|
||||
|
||||
# One form edits every row (payout[id][...]); payout[new][...] adds one,
|
||||
# filling empty fields from the built-in template for a known ticker.
|
||||
post '/admin/invoices/payout-addresses' do
|
||||
before = PayoutAddress.map
|
||||
errors = CryptoAsset::CODES.filter_map do |coin|
|
||||
next unless params.dig('address', coin)
|
||||
PayoutAddress.set!(coin, params['address'][coin], by: current_user.username)
|
||||
errors, changed = [], []
|
||||
(params['payout'] || {}).each do |id, raw|
|
||||
fresh = id == 'new'
|
||||
next if fresh && raw['address'].to_s.strip.empty?
|
||||
v = PayoutAddress.clean(raw, fill_from_template: fresh)
|
||||
if (e = PayoutAddress.error_for(v)) then errors << e; next end
|
||||
stamp = { updated_by: current_user.username, updated_at: Time.now.utc }
|
||||
if fresh
|
||||
row = PayoutAddress.create(**v, active: true, **stamp)
|
||||
changed << "+#{row.coin} #{row.name}"
|
||||
else
|
||||
row = PayoutAddress[id.to_i] or next
|
||||
row.set(**v, active: raw['active'] == '1')
|
||||
next unless row.modified?
|
||||
row.set(**stamp).save_changes
|
||||
changed << "#{row.coin} #{row.name}"
|
||||
end
|
||||
end
|
||||
changed = (before.keys | PayoutAddress.map.keys).reject { |c| before[c] == PayoutAddress.map[c] }
|
||||
audit!('invoice.payout_addresses', changed.join(', '), nil) if changed.any?
|
||||
session[:payout_flash] = errors.any? ? errors.join('; ') : "Saved#{" (#{changed.join(', ')})" if changed.any?}."
|
||||
audit!('invoice.payout_addresses', nil, changed.join('; ')) if changed.any?
|
||||
session[:payout_flash] = errors.any? ? errors.join('; ') : (changed.any? ? 'Saved.' : 'Nothing changed.')
|
||||
redirect '/admin/invoices/payout-addresses'
|
||||
end
|
||||
|
||||
# Existing invoices keep their own copy, so removal is safe.
|
||||
post '/admin/invoices/payout-addresses/:id/delete' do
|
||||
row = PayoutAddress[params[:id].to_i] or halt 404
|
||||
row.destroy
|
||||
audit!('invoice.payout_address_delete', row.coin, "#{row.name} #{row.address}")
|
||||
session[:payout_flash] = "Removed #{row.coin} · #{row.name}."
|
||||
redirect '/admin/invoices/payout-addresses'
|
||||
end
|
||||
|
||||
|
|
@ -280,8 +304,7 @@ class AsxpioWeb < Sinatra::Base
|
|||
issued_on: Date.today.strftime('%Y-%m-%d'),
|
||||
due_on: (Date.today + term).strftime('%Y-%m-%d'),
|
||||
notes: src.notes,
|
||||
crypto_coin: src.crypto_coin,
|
||||
crypto_address: src.crypto_address,
|
||||
payout_id: PayoutAddress.first(coin: src.crypto_coin, address: src.crypto_address, active: true)&.id.to_s,
|
||||
items: src.items_array
|
||||
}
|
||||
erb :'admin/invoices/new'
|
||||
|
|
@ -297,8 +320,7 @@ class AsxpioWeb < Sinatra::Base
|
|||
issued_on: params[:issued_on].to_s.strip,
|
||||
due_on: params[:due_on].to_s.strip,
|
||||
notes: params[:notes].to_s.strip,
|
||||
crypto_coin: params[:crypto_coin].to_s.upcase,
|
||||
crypto_address: params[:crypto_address].to_s.strip,
|
||||
payout_id: params[:payout_id].to_s.strip,
|
||||
crypto_rate: params[:crypto_rate].to_s.strip,
|
||||
crypto_amount: params[:crypto_amount].to_s.strip,
|
||||
items: (params[:items] || {}).values
|
||||
|
|
@ -317,9 +339,11 @@ class AsxpioWeb < Sinatra::Base
|
|||
# The PDF embeds the number, so a retry must re-render, not just re-save.
|
||||
# A losing attempt orphans its two S3 objects; harmless and near-impossible
|
||||
# with a single operator.
|
||||
build_params = @form_values.merge(@payout ? @payout.snapshot : {})
|
||||
|
||||
attempts = 0
|
||||
begin
|
||||
invoice = Invoice.build(@form_values)
|
||||
invoice = Invoice.build(build_params)
|
||||
# Base key (no suffix); the two status variants get -pending/-paid appended.
|
||||
invoice.pdf_key = "invoices/#{invoice.number}-#{invoice.uuid}.pdf"
|
||||
%w[pending paid].each do |st|
|
||||
|
|
@ -441,11 +465,12 @@ class AsxpioWeb < Sinatra::Base
|
|||
end
|
||||
v[:items] = items
|
||||
|
||||
# Crypto is optional; validate only when an address is present.
|
||||
unless v[:crypto_address].to_s.strip.empty?
|
||||
errors[:crypto_coin] = 'Unknown coin' unless CryptoAsset.valid?(v[:crypto_coin])
|
||||
# Loose shape check: every supported chain's addresses are alphanumeric.
|
||||
errors[:crypto_address] = 'Address looks invalid' unless v[:crypto_address] =~ /\A[a-zA-Z0-9]{10,128}\z/
|
||||
# Crypto is optional: a chosen payout address (validated when it was
|
||||
# saved) supplies ticker, address and QR metadata.
|
||||
@payout = nil
|
||||
unless v[:payout_id].to_s.empty?
|
||||
@payout = PayoutAddress.first(id: v[:payout_id].to_i, active: true)
|
||||
errors[:payout_id] = 'Pick an active payout address' unless @payout
|
||||
%i[crypto_rate crypto_amount].each do |k|
|
||||
next if v[k].to_s.strip.empty?
|
||||
begin
|
||||
|
|
|
|||
51
db/migrations/012_payout_addresses_rework.rb
Normal file
51
db/migrations/012_payout_addresses_rework.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' } })
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -14,37 +14,76 @@ class PayoutAddressTest < Minitest::Test
|
|||
DB.connection[:payout_addresses].delete
|
||||
end
|
||||
|
||||
def token
|
||||
get '/admin/invoices/payout-addresses'
|
||||
def token(path = '/admin/invoices/payout-addresses')
|
||||
get path
|
||||
last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
|
||||
end
|
||||
|
||||
def add(fields)
|
||||
post '/admin/invoices/payout-addresses', { authenticity_token: token, payout: { 'new' => fields } }
|
||||
end
|
||||
|
||||
def test_seed_from_env_once
|
||||
ENV['CRYPTO_ADDRESSES'] = '{"BTC":"bc1qseedseedseedseed","XYZ":"nope00000000"}'
|
||||
PayoutAddress.seed_from_env!
|
||||
assert_equal({ 'BTC' => 'bc1qseedseedseedseed' }, PayoutAddress.map)
|
||||
rows = PayoutAddress.all
|
||||
assert_equal [['BTC', 'bc1qseedseedseedseed', 'Bitcoin', 'bitcoin']], rows.map { |r| [r.coin, r.address, r.name, r.scheme] }
|
||||
ENV['CRYPTO_ADDRESSES'] = '{"BTC":"bc1qotherotherother"}'
|
||||
PayoutAddress.seed_from_env!
|
||||
assert_equal 'bc1qseedseedseedseed', PayoutAddress.map['BTC'] # not re-seeded
|
||||
assert_equal 1, PayoutAddress.count
|
||||
ensure
|
||||
ENV.delete('CRYPTO_ADDRESSES')
|
||||
end
|
||||
|
||||
def test_admin_edits_validates_and_removes
|
||||
def test_add_known_ticker_fills_from_template_and_custom_coin
|
||||
admin_login!
|
||||
post '/admin/invoices/payout-addresses', { authenticity_token: token, address: { 'LTC' => 'ltc1qaaaaaaaaaaaaaaaa', 'BTC' => 'bad addr!' } }
|
||||
assert_equal 302, last_response.status
|
||||
assert_equal 'ltc1qaaaaaaaaaaaaaaaa', PayoutAddress.map['LTC']
|
||||
assert_nil PayoutAddress.map['BTC']
|
||||
add('coin' => 'ltc', 'address' => 'ltc1qaaaaaaaaaaaaaaaa', 'name' => 'Litecoin (Ledger)')
|
||||
add('coin' => 'LTC', 'address' => 'ltc1qbbbbbbbbbbbbbbbb', 'name' => 'Litecoin (Exchange)')
|
||||
add('coin' => 'DOGE', 'address' => 'DAddr1234567890', 'name' => 'Dogecoin', 'scheme' => 'dogecoin:', 'amount_param' => 'amount', 'coingecko' => 'dogecoin')
|
||||
add('coin' => 'BTC', 'address' => 'bad addr!')
|
||||
led = PayoutAddress[address: 'ltc1qaaaaaaaaaaaaaaaa']
|
||||
assert_equal ['LTC', 'litecoin', 'amount', 'litecoin'], [led.coin, led.scheme, led.amount_param, led.coingecko]
|
||||
assert_equal 2, PayoutAddress.where(coin: 'LTC').count
|
||||
doge = PayoutAddress[coin: 'DOGE']
|
||||
assert_equal 'dogecoin', doge.scheme # colon stripped
|
||||
get '/admin/invoices/payout-addresses'
|
||||
assert_includes last_response.body, 'BTC: address looks invalid'
|
||||
assert_includes DB.connection[:audit_log].select_map(:action), 'invoice.payout_addresses'
|
||||
assert_includes last_response.body, 'QR → dogecoin:DAddr1234567890?amount=0.5'
|
||||
|
||||
get '/admin/invoices/new'
|
||||
assert_includes last_response.body, 'ltc1qaaaaaaaaaaaaaaaa' # prefilled from DB
|
||||
assert_includes last_response.body, 'Litecoin (Exchange) · LTC'
|
||||
assert_includes last_response.body, 'Dogecoin · DOGE'
|
||||
end
|
||||
|
||||
post '/admin/invoices/payout-addresses', { authenticity_token: token, address: { 'LTC' => '' } }
|
||||
assert_nil PayoutAddress.map['LTC']
|
||||
def test_edit_validate_retire_and_remove
|
||||
admin_login!
|
||||
add('coin' => 'LTC', 'address' => 'ltc1qaaaaaaaaaaaaaaaa')
|
||||
row = PayoutAddress.first
|
||||
post '/admin/invoices/payout-addresses', { authenticity_token: token,
|
||||
payout: { row.id.to_s => { 'coin' => 'LTC', 'name' => 'Main', 'address' => row.address, 'scheme' => 'Lite Coin', 'active' => '1' } } }
|
||||
assert_equal 'Litecoin', row.refresh.name # bad scheme: nothing saved
|
||||
get '/admin/invoices/payout-addresses'
|
||||
assert_includes last_response.body, "scheme must look like"
|
||||
|
||||
post '/admin/invoices/payout-addresses', { authenticity_token: token,
|
||||
payout: { row.id.to_s => { 'coin' => 'LTC', 'name' => 'Main', 'address' => row.address, 'scheme' => 'litecoin', 'amount_param' => 'amount' } } } # active unticked
|
||||
row.refresh
|
||||
assert_equal 'Main', row.name
|
||||
refute row.active
|
||||
get '/admin/invoices/new'
|
||||
refute_includes last_response.body, 'Main · LTC' # retired: not offered
|
||||
|
||||
post "/admin/invoices/payout-addresses/#{row.id}/delete", { authenticity_token: token }
|
||||
assert_nil PayoutAddress[row.id]
|
||||
assert_includes DB.connection[:audit_log].select_map(:action), 'invoice.payout_address_delete'
|
||||
end
|
||||
|
||||
def test_rate_endpoint_uses_payout_coingecko_id
|
||||
admin_login!
|
||||
add('coin' => 'XYZ', 'address' => 'XYZaddress1234567') # custom coin, no CoinGecko id
|
||||
get "/admin/crypto-rate?payout=#{PayoutAddress.first.id}¤cy=EUR"
|
||||
assert_equal 502, last_response.status
|
||||
assert_includes last_response.body, 'enter the rate by hand'
|
||||
end
|
||||
|
||||
def test_read_only_user_sees_disabled_form
|
||||
|
|
@ -53,9 +92,9 @@ class PayoutAddressTest < Minitest::Test
|
|||
admin_login!(username: 'reader', password: 'reader-password1')
|
||||
get '/admin/invoices/payout-addresses'
|
||||
assert last_response.ok?
|
||||
assert_includes last_response.body, 'disabled'
|
||||
refute_includes last_response.body, 'Save addresses'
|
||||
post '/admin/invoices/payout-addresses', { authenticity_token: token, address: { 'LTC' => 'ltc1qbbbbbbbbbbbbbbbb' } }
|
||||
refute_includes last_response.body, '>Save</button>'
|
||||
add('coin' => 'LTC', 'address' => 'ltc1qbbbbbbbbbbbbbbbb')
|
||||
assert_equal 403, last_response.status
|
||||
end
|
||||
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue