From 1dd8f9081f0cfa849b38d5484e8a01f6a50a7ca8 Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Sat, 10 Oct 2026 08:36:32 +0400 Subject: [PATCH] invoices: payout addresses in the database 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 --- CLAUDE.md | 6 +-- asxpio.rb | 23 +++++++++ db/migrations/010_payout_addresses.rb | 13 +++++ lib/crypto_asset.rb | 8 +-- lib/payout_address.rb | 36 +++++++++++++ test/crypto_test.rb | 10 ++-- test/payout_address_test.rb | 61 +++++++++++++++++++++++ views/admin/invoices/index.erb | 2 +- views/admin/invoices/new.erb | 4 +- views/admin/invoices/payout_addresses.erb | 30 +++++++++++ 10 files changed, 179 insertions(+), 14 deletions(-) create mode 100644 db/migrations/010_payout_addresses.rb create mode 100644 lib/payout_address.rb create mode 100644 test/payout_address_test.rb create mode 100644 views/admin/invoices/payout_addresses.erb diff --git a/CLAUDE.md b/CLAUDE.md index 54fc1bb..fb29079 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=¤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, 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). ### 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. | diff --git a/asxpio.rb b/asxpio.rb index b07be5f..447b15e 100644 --- a/asxpio.rb +++ b/asxpio.rb @@ -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 diff --git a/db/migrations/010_payout_addresses.rb b/db/migrations/010_payout_addresses.rb new file mode 100644 index 0000000..17c914b --- /dev/null +++ b/db/migrations/010_payout_addresses.rb @@ -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 diff --git a/lib/crypto_asset.rb b/lib/crypto_asset.rb index 8cf9480..78595d5 100644 --- a/lib/crypto_asset.rb +++ b/lib/crypto_asset.rb @@ -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 diff --git a/lib/payout_address.rb b/lib/payout_address.rb new file mode 100644 index 0000000..1a63605 --- /dev/null +++ b/lib/payout_address.rb @@ -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 diff --git a/test/crypto_test.rb b/test/crypto_test.rb index 1a9f044..323237e 100644 --- a/test/crypto_test.rb +++ b/test/crypto_test.rb @@ -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 diff --git a/test/payout_address_test.rb b/test/payout_address_test.rb new file mode 100644 index 0000000..0c5be1c --- /dev/null +++ b/test/payout_address_test.rb @@ -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 diff --git a/views/admin/invoices/index.erb b/views/admin/invoices/index.erb index 7f174e9..ec3c7ce 100644 --- a/views/admin/invoices/index.erb +++ b/views/admin/invoices/index.erb @@ -1,7 +1,7 @@ <%== erb :'admin/_nav', layout: false %>

Invoices

- <% if can?('invoices', 'write') %>

+ New invoice

<% end %> +

<% if can?('invoices', 'write') %>+ New invoice · <% end %>payout addresses

<% if @invoices.empty? %> diff --git a/views/admin/invoices/new.erb b/views/admin/invoices/new.erb index 7279592..f169575 100644 --- a/views/admin/invoices/new.erb +++ b/views/admin/invoices/new.erb @@ -97,7 +97,7 @@
@@ -115,7 +115,7 @@ placeholder="auto" inputmode="decimal" /> - + diff --git a/views/admin/invoices/payout_addresses.erb b/views/admin/invoices/payout_addresses.erb new file mode 100644 index 0000000..4a2085c --- /dev/null +++ b/views/admin/invoices/payout_addresses.erb @@ -0,0 +1,30 @@ +<%== erb :'admin/_nav', layout: false %> +
+

Payout addresses

+

← invoices · default address per coin, prefilled into new invoices

+
+ +<% if @flash %>
<%= @flash %>
<% end %> + +
+
+ +
> + + + + <% CryptoAsset::CODES.each do |coin| %> + <% row = @addresses[coin] %> + + + + + + <% end %> + +
CoinAddressUpdated
<%= coin %>
<%= CryptoAsset.name(coin) %>
<%= row ? "#{row.updated_at.strftime('%Y-%m-%d')} · #{row.updated_by}" : '—' %>
+

Leave a field empty to remove that default. Existing invoices keep the address captured when they were issued.

+ <% if can?('invoices', 'write') %><% end %> +
+
+