invoices: payout addresses in the database
All checks were successful
Build and Deploy to Production / test (push) Successful in 26s
Build and Deploy to Production / build (push) Successful in 59s
Build and Deploy to Production / deploy (push) Successful in 38s

Default crypto payout addresses lived in the CRYPTO_ADDRESSES and
LTC_ADDRESS Forgejo secrets, so changing one meant editing a secret and
redeploying. Keep them in a payout_addresses table edited at
/admin/invoices/payout-addresses (read to view, write to change, every
change audited), and prefill the new-invoice form from it.

The old env values are imported once on the first boot with an empty
table, so the current addresses carry over; after that the secrets can
be removed.

Assisted-by: Claude:opus-5
This commit is contained in:
Sergei Poljanski 2026-10-10 08:36:32 +04:00
commit 1dd8f9081f
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
10 changed files with 179 additions and 14 deletions

View file

@ -104,7 +104,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 `CRYPTO_ADDRESSES` env — JSON `code => address`; legacy `LTC_ADDRESS` still fills LTC), 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, 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).
### PDF rendering
@ -187,8 +187,8 @@ All colors in `public/style.css` are CSS custom properties on `:root` (design to
| `ASXPIO_DB_PASSWORD` | Password for the `asxpio` Postgres role | Update on **both** repos to the same value. Push storage first (re-provisions role), then asxpio. |
| `ASXPIO_S3_ACCESS_KEY` | MinIO service account key, scoped to `asxpio-invoices` | Same dual-repo update. Push storage first. |
| `ASXPIO_S3_SECRET_KEY` | Matching MinIO secret | Same. |
| `CRYPTO_ADDRESSES` | JSON object (`CryptoAsset` code => payout address) prefilled per coin into the new-invoice form | Not confidential (printed on invoices + QR), but kept as a secret so rotation is a one-place change. Update secret + push. Existing invoices keep their captured address. |
| `LTC_ADDRESS` | Legacy: fills the LTC default when `CRYPTO_ADDRESSES` has no LTC entry | Same as above. Can be folded into `CRYPTO_ADDRESSES` and removed. |
| `CRYPTO_ADDRESSES` | **Seed only.** Imported into `payout_addresses` on the first boot with an empty table; afterwards edited in `/admin/invoices/payout-addresses`. | Delete the secret (and its line in deploy.yaml) once the admin page shows the imported addresses. |
| `LTC_ADDRESS` | Legacy, seed only (fills LTC on that first import). | Delete together with `CRYPTO_ADDRESSES`. |
| `DEPLOY_IP/USER/SSH_KEY` | SSH to prod from CI | Standard SSH key rotation. |
| `FORGEJO_REGISTRY/USER`, `FJ_TOKEN` | Kaniko registry auth. Token needs `write:package` + `read:repository`; named `FJ_` because Forgejo forbids new secrets prefixed `FORGEJO_` | Forgejo token rotation. |

View file

@ -40,6 +40,8 @@ if ENV['DATABASE_URL']
Bingo.seed!
require_relative 'lib/user'
require_relative 'lib/audit'
require_relative 'lib/payout_address'
PayoutAddress.seed_from_env!
User.bootstrap!(ENV['ADMIN_USER'], ENV['ADMIN_PASSWORD'])
else
$logger.warn('DATABASE_URL not set — invoicing disabled')
@ -220,6 +222,27 @@ class AsxpioWeb < Sinatra::Base
end
end
# Defined before /admin/invoices/:uuid, which would otherwise match it.
get '/admin/invoices/payout-addresses' do
@page_title = 'Payout addresses · admin'
@noindex = true
@addresses = PayoutAddress.as_hash(:coin)
@flash = session.delete(:payout_flash)
erb :'admin/invoices/payout_addresses'
end
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)
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?}."
redirect '/admin/invoices/payout-addresses'
end
get '/admin/invoices/new' do
@page_title = 'New invoice · admin'
@noindex = true

View file

@ -0,0 +1,13 @@
Sequel.migration do
change do
# Default crypto payout address per CryptoAsset code, prefilled into the
# new-invoice form. Seeded once from the old CRYPTO_ADDRESSES/LTC_ADDRESS
# env; managed at /admin/invoices/payout-addresses since.
create_table(:payout_addresses) do
String :coin, primary_key: true
String :address, null: false
String :updated_by
DateTime :updated_at, null: false
end
end
end

View file

@ -47,9 +47,11 @@ module CryptoAsset
end
# Default payout addresses for the new-invoice form. CRYPTO_ADDRESSES is a
# JSON object (code => address); the legacy LTC_ADDRESS env still fills LTC.
# Unknown codes are dropped so a typo in the env can't invent an asset.
def default_addresses
# Legacy source, now only used to seed the payout_addresses table once
# (PayoutAddress.seed_from_env!): CRYPTO_ADDRESSES is a JSON object
# (code => address); LTC_ADDRESS still fills LTC. Unknown codes are
# dropped so a typo in the env can't invent an asset.
def env_addresses
map = begin
JSON.parse(ENV['CRYPTO_ADDRESSES'].to_s)
rescue JSON::ParserError

36
lib/payout_address.rb Normal file
View file

@ -0,0 +1,36 @@
require 'sequel'
require_relative 'crypto_asset'
# Default payout address per coin (see migration 010).
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
def self.map
to_hash(:coin, :address).slice(*CryptoAsset::CODES)
end
# First boot after the move: take over what the env/Forgejo secrets held.
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)
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
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
nil
end
end

View file

@ -17,9 +17,9 @@ class CryptoAssetTest < Minitest::Test
refute CryptoAsset.valid?('DOGE')
end
def test_default_addresses_from_json_env
def test_env_addresses_from_json_env
ENV['CRYPTO_ADDRESSES'] = '{"BTC":"bc1qtest","usdt-trc20":"Ttest","DOGE":"ignored"}'
map = CryptoAsset.default_addresses
map = CryptoAsset.env_addresses
assert_equal 'bc1qtest', map['BTC']
assert_equal 'Ttest', map['USDT-TRC20']
refute map.key?('DOGE')
@ -29,14 +29,14 @@ class CryptoAssetTest < Minitest::Test
def test_legacy_ltc_address_env_still_fills_ltc
ENV['LTC_ADDRESS'] = 'ltc1legacy'
assert_equal 'ltc1legacy', CryptoAsset.default_addresses['LTC']
assert_equal 'ltc1legacy', CryptoAsset.env_addresses['LTC']
ensure
ENV.delete('LTC_ADDRESS')
end
def test_default_addresses_survives_bad_json
def test_env_addresses_survives_bad_json
ENV['CRYPTO_ADDRESSES'] = 'not json'
assert_equal({}, CryptoAsset.default_addresses)
assert_equal({}, CryptoAsset.env_addresses)
ensure
ENV.delete('CRYPTO_ADDRESSES')
end

View file

@ -0,0 +1,61 @@
require_relative 'test_helper'
class PayoutAddressTest < Minitest::Test
include Rack::Test::Methods
include AdminLogin
def app
AsxpioWeb
end
def setup
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
TestDb.clean_users!
DB.connection[:payout_addresses].delete
end
def token
get '/admin/invoices/payout-addresses'
last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
end
def test_seed_from_env_once
ENV['CRYPTO_ADDRESSES'] = '{"BTC":"bc1qseedseedseedseed","XYZ":"nope00000000"}'
PayoutAddress.seed_from_env!
assert_equal({ 'BTC' => 'bc1qseedseedseedseed' }, PayoutAddress.map)
ENV['CRYPTO_ADDRESSES'] = '{"BTC":"bc1qotherotherother"}'
PayoutAddress.seed_from_env!
assert_equal 'bc1qseedseedseedseed', PayoutAddress.map['BTC'] # not re-seeded
ensure
ENV.delete('CRYPTO_ADDRESSES')
end
def test_admin_edits_validates_and_removes
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']
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'
get '/admin/invoices/new'
assert_includes last_response.body, 'ltc1qaaaaaaaaaaaaaaaa' # prefilled from DB
post '/admin/invoices/payout-addresses', { authenticity_token: token, address: { 'LTC' => '' } }
assert_nil PayoutAddress.map['LTC']
end
def test_read_only_user_sees_disabled_form
u = User.create(username: 'reader', password: 'reader-password1')
UserPermission.create(user_id: u.id, area: 'invoices', can_read: true)
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' } }
assert_equal 403, last_response.status
end
end

View file

@ -1,7 +1,7 @@
<%== erb :'admin/_nav', layout: false %>
<div class="header">
<h1>Invoices</h1>
<% if can?('invoices', 'write') %><p class="tagline"><a href="/admin/invoices/new">+ New invoice</a></p><% end %>
<p class="tagline"><% if can?('invoices', 'write') %><a href="/admin/invoices/new">+ New invoice</a> · <% end %><a href="/admin/invoices/payout-addresses">payout addresses</a></p>
</div>
<% if @invoices.empty? %>

View file

@ -97,7 +97,7 @@
<div class="field">
<label for="crypto_address">Address</label>
<input type="text" id="crypto_address" name="crypto_address"
value="<%= @form_values[:crypto_address] || CryptoAsset.default_addresses[@form_values[:crypto_coin] || 'LTC'] %>"
value="<%= @form_values[:crypto_address] || PayoutAddress.map[@form_values[:crypto_coin] || 'LTC'] %>"
placeholder="payout address" maxlength="128" />
</div>
</div>
@ -115,7 +115,7 @@
placeholder="auto" inputmode="decimal" />
</div>
</div>
<script type="application/json" id="crypto-defaults"><%== CryptoAsset.default_addresses.to_json %></script>
<script type="application/json" id="crypto-defaults"><%== PayoutAddress.map.to_json %></script>
<button type="submit" class="download-button">Create invoice</button>
</form>

View file

@ -0,0 +1,30 @@
<%== 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>
</div>
<% if @flash %><div class="form-error-base"><div><%= @flash %></div></div><% end %>
<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>
<% 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 %>
</fieldset>
</form>
</section>