invoices: multi-crypto payment support
One asset per invoice from a registry (lib/crypto_asset.rb): BTC, LTC, ETH, XMR, SOL, ALGO, USDT/USDC on ERC-20/TRC-20/BEP-20/Solana/Algorand. Migration 003 generalizes the ltc_* columns to crypto_* + crypto_coin (existing LTC invoices backfilled). QR payload adapts per asset: BIP21 amount URIs where supported, bare address for tokens with a network hint on the PDF. Default addresses come from the CRYPTO_ADDRESSES secret (JSON code=>address); LTC_ADDRESS still works as legacy.
This commit is contained in:
parent
f9c7001e7a
commit
0fbb6ee809
20 changed files with 410 additions and 143 deletions
|
|
@ -113,7 +113,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: '',
|
||||
ltc_address: '', ltc_rate: '', ltc_amount: '',
|
||||
crypto_coin: 'LTC', crypto_address: '', crypto_rate: '', crypto_amount: '',
|
||||
items: { '0' => { 'description' => 'Engineering — test', 'qty' => '2', 'unit_price' => '100.50' } } }
|
||||
end
|
||||
|
||||
|
|
@ -131,6 +131,22 @@ class AppTest < Minitest::Test
|
|||
assert_match %r{/admin/invoices/#{invoice.uuid}}, last_response.location
|
||||
end
|
||||
|
||||
def test_create_invoice_with_crypto
|
||||
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: ''
|
||||
)
|
||||
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 BigDecimal('201'), invoice.crypto_amount_due
|
||||
end
|
||||
|
||||
def test_create_invoice_validation_failure_rerenders_form
|
||||
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
|
||||
token = csrf_token_from('/admin/invoices/new', admin_env)
|
||||
|
|
|
|||
71
test/crypto_test.rb
Normal file
71
test/crypto_test.rb
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
require_relative 'test_helper'
|
||||
|
||||
require_relative '../lib/crypto_asset'
|
||||
require_relative '../lib/crypto_qr'
|
||||
|
||||
class CryptoAssetTest < Minitest::Test
|
||||
def test_registry_entries_are_complete
|
||||
CryptoAsset::ASSETS.each do |code, asset|
|
||||
assert asset[:name], "#{code} missing name"
|
||||
assert asset[:coingecko], "#{code} missing coingecko id"
|
||||
end
|
||||
end
|
||||
|
||||
def test_valid_is_case_insensitive
|
||||
assert CryptoAsset.valid?('btc')
|
||||
assert CryptoAsset.valid?('usdt-trc20')
|
||||
refute CryptoAsset.valid?('DOGE')
|
||||
end
|
||||
|
||||
def test_default_addresses_from_json_env
|
||||
ENV['CRYPTO_ADDRESSES'] = '{"BTC":"bc1qtest","usdt-trc20":"Ttest","DOGE":"ignored"}'
|
||||
map = CryptoAsset.default_addresses
|
||||
assert_equal 'bc1qtest', map['BTC']
|
||||
assert_equal 'Ttest', map['USDT-TRC20']
|
||||
refute map.key?('DOGE')
|
||||
ensure
|
||||
ENV.delete('CRYPTO_ADDRESSES')
|
||||
end
|
||||
|
||||
def test_legacy_ltc_address_env_still_fills_ltc
|
||||
ENV['LTC_ADDRESS'] = 'ltc1legacy'
|
||||
assert_equal 'ltc1legacy', CryptoAsset.default_addresses['LTC']
|
||||
ensure
|
||||
ENV.delete('LTC_ADDRESS')
|
||||
end
|
||||
|
||||
def test_default_addresses_survives_bad_json
|
||||
ENV['CRYPTO_ADDRESSES'] = 'not json'
|
||||
assert_equal({}, CryptoAsset.default_addresses)
|
||||
ensure
|
||||
ENV.delete('CRYPTO_ADDRESSES')
|
||||
end
|
||||
end
|
||||
|
||||
class CryptoQrTest < Minitest::Test
|
||||
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'))
|
||||
end
|
||||
|
||||
def test_monero_uses_tx_amount
|
||||
assert_equal 'monero:4Aaddr?tx_amount=2.5',
|
||||
CryptoQr.uri('XMR', '4Aaddr', BigDecimal('2.5'))
|
||||
end
|
||||
|
||||
def test_eth_scheme_without_amount
|
||||
assert_equal 'ethereum:0xAddr', CryptoQr.uri('ETH', '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)
|
||||
end
|
||||
|
||||
def test_png_renders
|
||||
png = CryptoQr.png('BTC', 'bc1qexampleexampleexample', BigDecimal('0.01'))
|
||||
assert png.start_with?("\x89PNG".b)
|
||||
end
|
||||
end
|
||||
|
|
@ -14,9 +14,9 @@ class FmtTest < Minitest::Test
|
|||
assert_equal '1,050.2500', Fmt.rate('1050.25')
|
||||
end
|
||||
|
||||
def test_ltc_trims_trailing_zeros
|
||||
assert_equal '1.25', Fmt.ltc('1.250000')
|
||||
assert_equal '2', Fmt.ltc('2.0')
|
||||
assert_equal '0.00123456', Fmt.ltc('0.00123456')
|
||||
def test_crypto_trims_trailing_zeros
|
||||
assert_equal '1.25', Fmt.crypto('1.250000')
|
||||
assert_equal '2', Fmt.crypto('2.0')
|
||||
assert_equal '0.00123456', Fmt.crypto('0.00123456')
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -55,25 +55,38 @@ class InvoiceTest < Minitest::Test
|
|||
assert_equal BigDecimal('201'), saved.total
|
||||
assert_equal BigDecimal('613.05'), saved.total_gel
|
||||
assert_equal 'pending', saved.status
|
||||
refute saved.ltc?
|
||||
refute saved.crypto?
|
||||
end
|
||||
|
||||
def test_build_derives_ltc_amount_from_rate
|
||||
def test_build_derives_crypto_amount_from_rate
|
||||
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' }],
|
||||
ltc_address: 'ltc1qexampleexampleexampleexample', ltc_rate: '80', ltc_amount: ''
|
||||
crypto_coin: 'ltc', crypto_address: 'ltc1qexampleexampleexampleexample',
|
||||
crypto_rate: '80', crypto_amount: ''
|
||||
)
|
||||
assert inv.ltc?
|
||||
assert_equal BigDecimal('1.25'), inv.ltc_amount_due
|
||||
assert inv.crypto?
|
||||
assert_equal 'LTC', inv.crypto_coin
|
||||
assert_equal BigDecimal('1.25'), inv.crypto_amount_due
|
||||
end
|
||||
|
||||
def test_build_rejects_unknown_coin
|
||||
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'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
class InvoiceValidationTest < Minitest::Test
|
||||
BASE = {
|
||||
client_name: 'ACME', client_email: 'billing@example.com', currency: 'EUR',
|
||||
gel_rate: '3.05', ltc_address: '', ltc_rate: '', ltc_amount: '',
|
||||
gel_rate: '3.05', crypto_coin: 'LTC', crypto_address: '', crypto_rate: '', crypto_amount: '',
|
||||
items: [{ 'description' => 'work', 'qty' => '1', 'unit_price' => '100' }]
|
||||
}.freeze
|
||||
|
||||
|
|
@ -109,7 +122,17 @@ class InvoiceValidationTest < Minitest::Test
|
|||
assert_includes validate(items: [{ 'description' => '', 'qty' => '1', 'unit_price' => '1' }]), :items
|
||||
end
|
||||
|
||||
def test_ltc_address_format
|
||||
assert_includes validate(ltc_address: 'not-an-address'), :ltc_address
|
||||
def test_crypto_address_format
|
||||
assert_includes validate(crypto_address: 'not!an@address'), :crypto_address
|
||||
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
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue