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
This commit is contained in:
parent
eeac07ed34
commit
1dd8f9081f
10 changed files with 179 additions and 14 deletions
|
|
@ -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. |
|
||||
|
||||
|
|
|
|||
23
asxpio.rb
23
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
|
||||
|
|
|
|||
13
db/migrations/010_payout_addresses.rb
Normal file
13
db/migrations/010_payout_addresses.rb
Normal 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
|
||||
|
|
@ -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
36
lib/payout_address.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
61
test/payout_address_test.rb
Normal file
61
test/payout_address_test.rb
Normal 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
|
||||
|
|
@ -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? %>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
30
views/admin/invoices/payout_addresses.erb
Normal file
30
views/admin/invoices/payout_addresses.erb
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue