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
|
|
@ -11,6 +11,9 @@ SMTP_PASSWORD=change-me
|
|||
MAIL_FROM="IE Sergei Poljanski Contact Form <me@asxp.io>"
|
||||
MAIL_TO=ie@asxp.io
|
||||
|
||||
# Optional: default Litecoin payout address prefilled into the new-invoice form.
|
||||
# Production sets this from the LTC_ADDRESS Forgejo secret; leave blank locally.
|
||||
# Optional: default crypto payout addresses prefilled into the new-invoice
|
||||
# form. JSON object, CryptoAsset code => address (codes in lib/crypto_asset.rb).
|
||||
# Production sets this from the CRYPTO_ADDRESSES Forgejo secret.
|
||||
# CRYPTO_ADDRESSES={"BTC":"bc1...","USDT-TRC20":"T..."}
|
||||
# Legacy: LTC_ADDRESS still fills the LTC default when set.
|
||||
# LTC_ADDRESS=ltc1...
|
||||
|
|
|
|||
|
|
@ -109,11 +109,12 @@ jobs:
|
|||
ASXPIO_S3_ACCESS_KEY: ${{ secrets.ASXPIO_S3_ACCESS_KEY }}
|
||||
ASXPIO_S3_SECRET_KEY: ${{ secrets.ASXPIO_S3_SECRET_KEY }}
|
||||
LTC_ADDRESS: ${{ secrets.LTC_ADDRESS }}
|
||||
CRYPTO_ADDRESSES: ${{ secrets.CRYPTO_ADDRESSES }}
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_IP }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
envs: SESSION_SECRET,SMTP_PASSWORD,ADMIN_USER,ADMIN_PASSWORD,ASXPIO_DB_PASSWORD,ASXPIO_S3_ACCESS_KEY,ASXPIO_S3_SECRET_KEY,LTC_ADDRESS
|
||||
envs: SESSION_SECRET,SMTP_PASSWORD,ADMIN_USER,ADMIN_PASSWORD,ASXPIO_DB_PASSWORD,ASXPIO_S3_ACCESS_KEY,ASXPIO_S3_SECRET_KEY,LTC_ADDRESS,CRYPTO_ADDRESSES
|
||||
script_stop: true
|
||||
script: |
|
||||
umask 077
|
||||
|
|
@ -137,6 +138,7 @@ jobs:
|
|||
S3_ACCESS_KEY=${ASXPIO_S3_ACCESS_KEY}
|
||||
S3_SECRET_KEY=${ASXPIO_S3_SECRET_KEY}
|
||||
LTC_ADDRESS=${LTC_ADDRESS}
|
||||
CRYPTO_ADDRESSES=${CRYPTO_ADDRESSES}
|
||||
EOF
|
||||
chmod 600 /opt/asxpio/.env
|
||||
|
||||
|
|
|
|||
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -89,12 +89,12 @@ Public:
|
|||
### Storage model
|
||||
|
||||
- `invoices` table (`db/migrations/001_invoices.rb`): UUID PK, unique `number` (format `INV-{YYYY}-{NNNN}`, allocated by scanning the current year's max), JSONB `items`, captured-at-issue `gel_rate`, `paid_at` nullable, `pdf_key` for the MinIO object path (`invoices/<number>-<uuid>.pdf`).
|
||||
- `db/migrations/002_invoice_ltc.rb` adds optional Litecoin payment columns: `ltc_address` (captured at issue), `ltc_rate` (LTC price in the invoice currency, snapshotted), `ltc_amount` (LTC due — derived `total / ltc_rate` unless hand-overridden). All nullable; LTC is opt-in per invoice (blank address ⇒ no LTC block/QR).
|
||||
- `Invoice` (Sequel model) — number allocation, line-item normalization, `total` / `total_gel` helpers, plus `ltc?` and `ltc_amount_due`. **Always assign `uuid` as an attribute after `Invoice.new(...)`** — passing it to `new` raises `MassAssignmentRestriction` because Sequel guards primary keys.
|
||||
- `db/migrations/002_invoice_ltc.rb` added Litecoin-only columns; `003_invoice_crypto.rb` generalized them: `crypto_coin` (a `CryptoAsset` code, e.g. `BTC`, `USDT-TRC20`), `crypto_address` (captured at issue), `crypto_rate` (price in the invoice currency, snapshotted), `crypto_amount` (due — derived `total / crypto_rate` unless hand-overridden). All nullable; crypto is opt-in per invoice (blank address ⇒ no crypto block/QR). Pre-migration LTC invoices were backfilled with `crypto_coin = 'LTC'`.
|
||||
- `Invoice` (Sequel model) — number allocation, line-item normalization, `total` / `total_gel` helpers, plus `crypto?` and `crypto_amount_due`. **Always assign `uuid` as an attribute after `Invoice.new(...)`** — passing it to `new` raises `MassAssignmentRestriction` because Sequel guards primary keys.
|
||||
|
||||
### Litecoin payment
|
||||
### Crypto payment
|
||||
|
||||
Opt-in per invoice. The new-invoice form has an LTC section (address prefilled from the `LTC_ADDRESS` env / Forgejo secret, editable), a rate field with a "Fetch live" button, and an editable amount that overrides the derived value. `GET /admin/ltc-rate?currency=&gel_rate=` returns the live LTC price as JSON via `lib/ltc_rate.rb` (CoinGecko, no API key; USD/EUR direct, GEL derived from the form's `gel_rate`). `lib/ltc_qr.rb` renders a `litecoin:<addr>?amount=<ltc>` BIP21 QR (rqrcode + chunky_png) embedded in the PDF.
|
||||
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).
|
||||
|
||||
### PDF rendering
|
||||
|
||||
|
|
@ -156,7 +156,8 @@ The hedgehog is the user's pre-IE personal mark; once the IE has its own logo, t
|
|||
| `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. |
|
||||
| `LTC_ADDRESS` | Default Litecoin payout address prefilled into the new-invoice form | Not confidential (printed on every invoice + QR), but kept as a secret so rotation is a one-place change. Update secret + push. Existing invoices keep their captured address. |
|
||||
| `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. |
|
||||
| `DEPLOY_IP/USER/SSH_KEY` | SSH to prod from CI | Standard SSH key rotation. |
|
||||
| `FORGEJO_REGISTRY/USER/TOKEN` | Kaniko registry auth | Forgejo token rotation. |
|
||||
|
||||
|
|
|
|||
43
asxpio.rb
43
asxpio.rb
|
|
@ -29,8 +29,9 @@ if ENV['DATABASE_URL']
|
|||
DB.connect!
|
||||
DB.migrate!
|
||||
require_relative 'lib/invoice'
|
||||
require_relative 'lib/ltc_rate'
|
||||
require_relative 'lib/ltc_qr'
|
||||
require_relative 'lib/crypto_asset'
|
||||
require_relative 'lib/crypto_rate'
|
||||
require_relative 'lib/crypto_qr'
|
||||
require_relative 'lib/gel_rate'
|
||||
require_relative 'lib/invoice_pdf'
|
||||
else
|
||||
|
|
@ -78,8 +79,8 @@ class AsxpioWeb < Sinatra::Base
|
|||
Fmt.rate(value, min_dp: min_dp)
|
||||
end
|
||||
|
||||
def fmt_ltc(value)
|
||||
Fmt.ltc(value)
|
||||
def fmt_crypto(value)
|
||||
Fmt.crypto(value)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -182,16 +183,18 @@ class AsxpioWeb < Sinatra::Base
|
|||
erb :'admin/invoices/index'
|
||||
end
|
||||
|
||||
# Live LTC price for the new-invoice form's "Fetch live" button.
|
||||
# GEL is derived from the gel_rate the operator typed into the form.
|
||||
get '/admin/ltc-rate' do
|
||||
# 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.
|
||||
get '/admin/crypto-rate' do
|
||||
content_type :json
|
||||
coin = params[:coin].to_s.upcase
|
||||
currency = params[:currency].to_s.upcase
|
||||
gel_rate = params[:gel_rate].to_s.strip
|
||||
begin
|
||||
rate = LtcRate.fetch(currency, gel_rate: gel_rate.empty? ? nil : gel_rate)
|
||||
{ rate: rate.round(8).to_s('F'), currency: currency }.to_json
|
||||
rescue LtcRate::Error => e
|
||||
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
|
||||
rescue CryptoRate::Error => e
|
||||
status 502
|
||||
{ error: e.message }.to_json
|
||||
end
|
||||
|
|
@ -249,7 +252,8 @@ class AsxpioWeb < Sinatra::Base
|
|||
issued_on: Date.today.strftime('%Y-%m-%d'),
|
||||
due_on: (Date.today + term).strftime('%Y-%m-%d'),
|
||||
notes: src.notes,
|
||||
ltc_address: src.ltc_address,
|
||||
crypto_coin: src.crypto_coin,
|
||||
crypto_address: src.crypto_address,
|
||||
items: src.items_array
|
||||
}
|
||||
erb :'admin/invoices/new'
|
||||
|
|
@ -265,9 +269,10 @@ 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,
|
||||
ltc_address: params[:ltc_address].to_s.strip,
|
||||
ltc_rate: params[:ltc_rate].to_s.strip,
|
||||
ltc_amount: params[:ltc_amount].to_s.strip,
|
||||
crypto_coin: params[:crypto_coin].to_s.upcase,
|
||||
crypto_address: params[:crypto_address].to_s.strip,
|
||||
crypto_rate: params[:crypto_rate].to_s.strip,
|
||||
crypto_amount: params[:crypto_amount].to_s.strip,
|
||||
items: (params[:items] || {}).values
|
||||
}
|
||||
@form_errors = validate_invoice_params(@form_values)
|
||||
|
|
@ -375,10 +380,12 @@ class AsxpioWeb < Sinatra::Base
|
|||
end
|
||||
v[:items] = items
|
||||
|
||||
# LTC is optional; validate only when an address is present.
|
||||
unless v[:ltc_address].to_s.strip.empty?
|
||||
errors[:ltc_address] = 'LTC address looks invalid' unless v[:ltc_address] =~ /\A(ltc1|[LM3])[a-zA-HJ-NP-Z0-9]{20,90}\z/
|
||||
%i[ltc_rate ltc_amount].each do |k|
|
||||
# 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/
|
||||
%i[crypto_rate crypto_amount].each do |k|
|
||||
next if v[k].to_s.strip.empty?
|
||||
begin
|
||||
raise ArgumentError if BigDecimal(v[k]) <= 0
|
||||
|
|
|
|||
22
db/migrations/003_invoice_crypto.rb
Normal file
22
db/migrations/003_invoice_crypto.rb
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
Sequel.migration do
|
||||
up do
|
||||
alter_table(:invoices) do
|
||||
# Generalize the Litecoin-only columns: one crypto asset per invoice,
|
||||
# identified by crypto_coin (a CryptoAsset code, e.g. 'BTC', 'USDT-TRC20').
|
||||
rename_column :ltc_address, :crypto_address
|
||||
rename_column :ltc_rate, :crypto_rate
|
||||
rename_column :ltc_amount, :crypto_amount
|
||||
add_column :crypto_coin, String
|
||||
end
|
||||
run "UPDATE invoices SET crypto_coin = 'LTC' WHERE crypto_address IS NOT NULL"
|
||||
end
|
||||
|
||||
down do
|
||||
alter_table(:invoices) do
|
||||
drop_column :crypto_coin
|
||||
rename_column :crypto_address, :ltc_address
|
||||
rename_column :crypto_rate, :ltc_rate
|
||||
rename_column :crypto_amount, :ltc_amount
|
||||
end
|
||||
end
|
||||
end
|
||||
64
lib/crypto_asset.rb
Normal file
64
lib/crypto_asset.rb
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
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).
|
||||
#
|
||||
# QR encoding is driven by scheme/amount_param:
|
||||
# scheme + amount_param => scheme:<addr>?<param>=<amt> (wallet prefills amount)
|
||||
# scheme only => scheme:<addr> (amount shown as text)
|
||||
# neither => bare address (tokens/chains with no
|
||||
# standard payment URI)
|
||||
# coingecko is the id for the simple/price endpoint; chain variants of a
|
||||
# stablecoin share the token's id.
|
||||
module CryptoAsset
|
||||
ASSETS = {
|
||||
'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 },
|
||||
'XMR' => { name: 'Monero', coingecko: 'monero', scheme: 'monero', amount_param: 'tx_amount' },
|
||||
'SOL' => { name: 'Solana', coingecko: 'solana', scheme: 'solana', amount_param: 'amount' },
|
||||
'ALGO' => { name: 'Algorand', coingecko: 'algorand', scheme: nil, amount_param: nil },
|
||||
'USDT-ERC20' => { name: 'Tether (Ethereum)', coingecko: 'tether', scheme: nil, amount_param: nil },
|
||||
'USDT-TRC20' => { name: 'Tether (Tron)', coingecko: 'tether', scheme: nil, amount_param: nil },
|
||||
'USDT-BEP20' => { name: 'Tether (BNB Chain)', coingecko: 'tether', scheme: nil, amount_param: nil },
|
||||
'USDT-SOL' => { name: 'Tether (Solana)', coingecko: 'tether', scheme: nil, amount_param: nil },
|
||||
'USDC-ERC20' => { name: 'USD Coin (Ethereum)', coingecko: 'usd-coin', scheme: nil, amount_param: nil },
|
||||
'USDC-BEP20' => { name: 'USD Coin (BNB Chain)', coingecko: 'usd-coin', scheme: nil, amount_param: nil },
|
||||
'USDC-SOL' => { name: 'USD Coin (Solana)', coingecko: 'usd-coin', scheme: nil, amount_param: nil },
|
||||
'USDC-ALGO' => { name: 'USD Coin (Algorand)', coingecko: 'usd-coin', scheme: nil, amount_param: nil }
|
||||
}.freeze
|
||||
|
||||
CODES = ASSETS.keys.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def [](code)
|
||||
ASSETS[code.to_s.upcase]
|
||||
end
|
||||
|
||||
def valid?(code)
|
||||
ASSETS.key?(code.to_s.upcase)
|
||||
end
|
||||
|
||||
def name(code)
|
||||
asset = self[code]
|
||||
asset ? asset[:name] : code.to_s
|
||||
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
|
||||
map = begin
|
||||
JSON.parse(ENV['CRYPTO_ADDRESSES'].to_s)
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
map = {} unless map.is_a?(Hash)
|
||||
map = map.transform_keys { |k| k.to_s.upcase }
|
||||
ltc = ENV['LTC_ADDRESS'].to_s
|
||||
map['LTC'] = ltc unless ltc.empty? || map.key?('LTC')
|
||||
map.slice(*CODES)
|
||||
end
|
||||
end
|
||||
33
lib/crypto_qr.rb
Normal file
33
lib/crypto_qr.rb
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
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.
|
||||
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?
|
||||
# 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}"
|
||||
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)
|
||||
qr.as_png(
|
||||
bit_depth: 1,
|
||||
border_modules: 2,
|
||||
color: 'black',
|
||||
fill: 'white',
|
||||
module_px_size: 6,
|
||||
resize_exactly_to: size
|
||||
).to_s
|
||||
end
|
||||
end
|
||||
|
|
@ -2,13 +2,13 @@ require 'net/http'
|
|||
require 'json'
|
||||
require 'uri'
|
||||
require 'bigdecimal'
|
||||
require_relative 'crypto_asset'
|
||||
|
||||
# Fetches the current Litecoin price from CoinGecko (free, no API key).
|
||||
# Fetches the current price of a CryptoAsset from CoinGecko (free, no API key).
|
||||
#
|
||||
# CoinGecko's simple/price supports usd and eur directly. GEL is not a
|
||||
# CoinGecko vs_currency we rely on, so GEL is derived from the invoice's own
|
||||
# captured gel_rate (USD->GEL): ltc_in_gel = ltc_in_usd * gel_rate.
|
||||
module LtcRate
|
||||
# CoinGecko's simple/price supports usd and eur directly. GEL is derived from
|
||||
# the invoice's own captured gel_rate (USD->GEL): price_gel = price_usd * gel_rate.
|
||||
module CryptoRate
|
||||
ENDPOINT = 'https://api.coingecko.com/api/v3/simple/price'.freeze
|
||||
SUPPORTED = %w[USD EUR].freeze
|
||||
TIMEOUT = 6 # seconds
|
||||
|
|
@ -17,25 +17,27 @@ module LtcRate
|
|||
|
||||
module_function
|
||||
|
||||
# Returns a BigDecimal: the price of 1 LTC in `currency`.
|
||||
# Returns a BigDecimal: the price of 1 unit of `coin` in `currency`.
|
||||
# `gel_rate` (USD->GEL) is required only when currency == 'GEL'.
|
||||
def fetch(currency, gel_rate: nil)
|
||||
def fetch(coin, currency, gel_rate: nil)
|
||||
asset = CryptoAsset[coin] or raise Error, "Unknown coin: #{coin}"
|
||||
id = asset[:coingecko]
|
||||
currency = currency.to_s.upcase
|
||||
|
||||
if currency == 'GEL'
|
||||
raise Error, 'gel_rate required to derive LTC/GEL' if gel_rate.nil?
|
||||
usd = fetch_simple('usd')
|
||||
raise Error, 'gel_rate required to derive the GEL price' if gel_rate.nil?
|
||||
usd = fetch_simple(id, 'usd')
|
||||
(usd * BigDecimal(gel_rate.to_s)).round(8)
|
||||
elsif SUPPORTED.include?(currency)
|
||||
fetch_simple(currency.downcase).round(8)
|
||||
fetch_simple(id, currency.downcase).round(8)
|
||||
else
|
||||
raise Error, "Unsupported currency: #{currency}"
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_simple(vs)
|
||||
def fetch_simple(id, vs)
|
||||
uri = URI(ENDPOINT)
|
||||
uri.query = URI.encode_www_form(ids: 'litecoin', vs_currencies: vs)
|
||||
uri.query = URI.encode_www_form(ids: id, vs_currencies: vs)
|
||||
|
||||
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
|
||||
open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
|
||||
|
|
@ -45,8 +47,8 @@ module LtcRate
|
|||
raise Error, "CoinGecko HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
|
||||
|
||||
data = JSON.parse(res.body)
|
||||
price = data.dig('litecoin', vs)
|
||||
raise Error, "No price for #{vs} in response" if price.nil?
|
||||
price = data.dig(id, vs)
|
||||
raise Error, "No price for #{id}/#{vs} in response" if price.nil?
|
||||
|
||||
BigDecimal(price.to_s)
|
||||
rescue JSON::ParserError => e
|
||||
|
|
@ -13,8 +13,8 @@ module Fmt
|
|||
"#{group(int)}.#{frac}"
|
||||
end
|
||||
|
||||
# LTC amount: up to 8 dp, trailing zeros trimmed, no padding.
|
||||
def ltc(value)
|
||||
# Crypto amount: up to 8 dp, trailing zeros trimmed, no padding.
|
||||
def crypto(value)
|
||||
BigDecimal(value.to_s).round(8).to_s('F').sub(/(\.\d*?)0+$/, '\1').sub(/\.$/, '')
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ require 'sequel'
|
|||
require 'securerandom'
|
||||
require 'json'
|
||||
require 'bigdecimal'
|
||||
require_relative 'crypto_asset'
|
||||
|
||||
class Invoice < Sequel::Model(:invoices)
|
||||
CURRENCIES = %w[USD EUR GEL].freeze
|
||||
|
|
@ -46,17 +47,17 @@ class Invoice < Sequel::Model(:invoices)
|
|||
(total * BigDecimal(gel_rate.to_s)).round(2)
|
||||
end
|
||||
|
||||
def ltc?
|
||||
!ltc_address.to_s.strip.empty?
|
||||
def crypto?
|
||||
!crypto_address.to_s.strip.empty?
|
||||
end
|
||||
|
||||
# The LTC amount to display / encode in the QR. Returns nil when no amount
|
||||
# is known (address-only invoice).
|
||||
def ltc_amount_due
|
||||
return nil unless ltc?
|
||||
return BigDecimal(ltc_amount.to_s) unless ltc_amount.nil?
|
||||
return nil if ltc_rate.nil? || BigDecimal(ltc_rate.to_s).zero?
|
||||
(total / BigDecimal(ltc_rate.to_s)).round(8)
|
||||
# The crypto amount to display / encode in the QR. Returns nil when no
|
||||
# amount is known (address-only invoice).
|
||||
def crypto_amount_due
|
||||
return nil unless crypto?
|
||||
return BigDecimal(crypto_amount.to_s) unless crypto_amount.nil?
|
||||
return nil if crypto_rate.nil? || BigDecimal(crypto_rate.to_s).zero?
|
||||
(total / BigDecimal(crypto_rate.to_s)).round(8)
|
||||
end
|
||||
|
||||
class << self
|
||||
|
|
@ -89,27 +90,32 @@ class Invoice < Sequel::Model(:invoices)
|
|||
pdf_key: '',
|
||||
created_at: Time.now.utc
|
||||
)
|
||||
apply_ltc(invoice, params, subtotal)
|
||||
apply_crypto(invoice, params, subtotal)
|
||||
invoice.uuid = SecureRandom.uuid
|
||||
invoice
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# LTC is opt-in: only populated when an address is given. Rate and amount
|
||||
# are both optional; if amount is blank but rate is present, derive it.
|
||||
def apply_ltc(invoice, params, subtotal)
|
||||
address = params[:ltc_address].to_s.strip
|
||||
# Crypto is opt-in: only populated when an address is given. Rate and
|
||||
# amount are both optional; if amount is blank but rate is present,
|
||||
# derive it.
|
||||
def apply_crypto(invoice, params, subtotal)
|
||||
address = params[:crypto_address].to_s.strip
|
||||
return if address.empty?
|
||||
|
||||
invoice.ltc_address = address
|
||||
coin = params[:crypto_coin].to_s.upcase
|
||||
CryptoAsset.valid?(coin) or raise ArgumentError, "Unknown coin: #{coin}"
|
||||
|
||||
rate = decimal_or_nil(params[:ltc_rate])
|
||||
invoice.ltc_rate = rate
|
||||
invoice.crypto_coin = coin
|
||||
invoice.crypto_address = address
|
||||
|
||||
amount = decimal_or_nil(params[:ltc_amount])
|
||||
rate = decimal_or_nil(params[:crypto_rate])
|
||||
invoice.crypto_rate = rate
|
||||
|
||||
amount = decimal_or_nil(params[:crypto_amount])
|
||||
amount ||= (subtotal / rate).round(8) if rate && !rate.zero?
|
||||
invoice.ltc_amount = amount
|
||||
invoice.crypto_amount = amount
|
||||
end
|
||||
|
||||
def decimal_or_nil(raw)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ require 'prawn/table'
|
|||
require 'bigdecimal'
|
||||
require 'stringio'
|
||||
require_relative 'fmt'
|
||||
require_relative 'crypto_asset'
|
||||
require_relative 'crypto_qr'
|
||||
|
||||
class InvoicePdf
|
||||
FONTS_DIR = File.join($root, 'public', 'fonts')
|
||||
|
|
@ -246,7 +248,7 @@ class InvoicePdf
|
|||
pdf.text "Beneficiary: #{ISSUER[:name_latin]}"
|
||||
end
|
||||
|
||||
draw_ltc(pdf) if @invoice.respond_to?(:ltc?) && @invoice.ltc?
|
||||
draw_crypto(pdf) if @invoice.respond_to?(:crypto?) && @invoice.crypto?
|
||||
|
||||
unless @invoice.notes.to_s.strip.empty?
|
||||
pdf.move_down 14
|
||||
|
|
@ -258,20 +260,22 @@ class InvoicePdf
|
|||
end
|
||||
end
|
||||
|
||||
def draw_ltc(pdf)
|
||||
amount = @invoice.ltc_amount_due
|
||||
def draw_crypto(pdf)
|
||||
coin = @invoice.crypto_coin
|
||||
asset = CryptoAsset[coin] || {}
|
||||
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 LITECOIN (LTC)', character_spacing: 1.5 }
|
||||
pdf.font_size(8) { pdf.text "PAY IN #{CryptoAsset.name(coin).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 = LtcQr.png(@invoice.ltc_address, amount)
|
||||
qr_bytes = CryptoQr.png(coin, @invoice.crypto_address, 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
|
||||
|
|
@ -282,19 +286,24 @@ class InvoicePdf
|
|||
pdf.bounding_box([0, pdf.bounds.top], width: pdf.bounds.width - qr_size - 16) do
|
||||
pdf.font_size(9) do
|
||||
if amount
|
||||
pdf.text "Amount: #{fmt_ltc(amount)} LTC", style: :bold
|
||||
if @invoice.ltc_rate
|
||||
pdf.text "Amount: #{fmt_crypto(amount)} #{coin}", style: :bold
|
||||
if @invoice.crypto_rate
|
||||
pdf.fill_color COLOR_MUTED
|
||||
pdf.text "(rate 1 LTC = #{fmt_rate(@invoice.ltc_rate)} #{@invoice.currency} at issue)", size: 8
|
||||
pdf.text "(rate 1 #{coin} = #{fmt_rate(@invoice.crypto_rate)} #{@invoice.currency} at issue)", size: 8
|
||||
pdf.fill_color COLOR_TEXT
|
||||
end
|
||||
pdf.move_down 3
|
||||
end
|
||||
pdf.text 'Address:'
|
||||
pdf.font_size(8) { pdf.text @invoice.ltc_address }
|
||||
pdf.font_size(8) { pdf.text @invoice.crypto_address }
|
||||
pdf.move_down 3
|
||||
pdf.fill_color COLOR_MUTED
|
||||
pdf.font_size(7) { pdf.text 'Scan the QR with any Litecoin wallet to prefill the payment.' }
|
||||
hint = if asset[:amount_param]
|
||||
"Scan the QR with any #{CryptoAsset.name(coin)} wallet to prefill the payment."
|
||||
else
|
||||
"QR encodes the address. Send #{coin} only — verify the network before sending."
|
||||
end
|
||||
pdf.font_size(7) { pdf.text hint }
|
||||
pdf.fill_color COLOR_TEXT
|
||||
end
|
||||
end
|
||||
|
|
@ -333,8 +342,8 @@ class InvoicePdf
|
|||
BigDecimal(value.to_s).round(2).to_s('F').then { |s| with_thousands(s) }
|
||||
end
|
||||
|
||||
def fmt_ltc(value)
|
||||
Fmt.ltc(value)
|
||||
def fmt_crypto(value)
|
||||
Fmt.crypto(value)
|
||||
end
|
||||
|
||||
def fmt_rate(value, min_dp: 4)
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
require 'rqrcode'
|
||||
|
||||
# Renders a Litecoin payment QR as PNG bytes for embedding in the PDF.
|
||||
# Encodes a BIP21-style URI: litecoin:<address>?amount=<ltc>
|
||||
module LtcQr
|
||||
module_function
|
||||
|
||||
# `amount` is a BigDecimal LTC amount or nil (address-only QR).
|
||||
def uri(address, amount)
|
||||
base = "litecoin:#{address}"
|
||||
return base if amount.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}?amount=#{amt}"
|
||||
end
|
||||
|
||||
# Returns PNG bytes (binary string), sized for a crisp ~120pt PDF placement.
|
||||
def png(address, amount, size: 480)
|
||||
data = uri(address, amount)
|
||||
qr = RQRCode::QRCode.new(data, level: :m)
|
||||
qr.as_png(
|
||||
bit_depth: 1,
|
||||
border_modules: 2,
|
||||
color: 'black',
|
||||
fill: 'white',
|
||||
module_px_size: 6,
|
||||
resize_exactly_to: size
|
||||
).to_s
|
||||
end
|
||||
end
|
||||
|
|
@ -36,15 +36,40 @@
|
|||
reindex();
|
||||
});
|
||||
|
||||
// --- Litecoin: live rate fetch + auto amount ------------------------
|
||||
const rateInput = document.getElementById('ltc_rate');
|
||||
const amountInput = document.getElementById('ltc_amount');
|
||||
const fetchBtn = document.getElementById('fetch-ltc-rate');
|
||||
const rateStatus = document.getElementById('ltc-rate-status');
|
||||
const rateCcy = document.getElementById('ltc-rate-ccy');
|
||||
// --- Crypto: coin select, live rate fetch + auto amount -------------
|
||||
const coinSel = document.getElementById('crypto_coin');
|
||||
const addressInput = document.getElementById('crypto_address');
|
||||
const rateInput = document.getElementById('crypto_rate');
|
||||
const amountInput = document.getElementById('crypto_amount');
|
||||
const fetchBtn = document.getElementById('fetch-crypto-rate');
|
||||
const rateStatus = document.getElementById('crypto-rate-status');
|
||||
const rateCcy = document.getElementById('crypto-rate-ccy');
|
||||
const rateCoin = document.getElementById('crypto-rate-coin');
|
||||
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 */ }
|
||||
}
|
||||
|
||||
// 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] || '';
|
||||
rateInput.value = '';
|
||||
if (amountInput) amountInput.value = '';
|
||||
amountTouched = false;
|
||||
rateStatus.textContent = '';
|
||||
syncCcyLabel();
|
||||
});
|
||||
}
|
||||
|
||||
function invoiceTotal() {
|
||||
let total = 0;
|
||||
tbody.querySelectorAll('tr.item-row').forEach((row) => {
|
||||
|
|
@ -74,6 +99,7 @@
|
|||
const ccy = currencySel ? currencySel.value : 'USD';
|
||||
if (rateCcy) rateCcy.textContent = ccy;
|
||||
if (gelRateCcy) gelRateCcy.textContent = ccy;
|
||||
if (rateCoin && coinSel) rateCoin.textContent = coinSel.value;
|
||||
}
|
||||
syncCcyLabel();
|
||||
if (currencySel) currencySel.addEventListener('change', syncCcyLabel);
|
||||
|
|
@ -84,10 +110,11 @@
|
|||
if (fetchBtn) {
|
||||
fetchBtn.addEventListener('click', function () {
|
||||
const ccy = currencySel ? currencySel.value : 'USD';
|
||||
const params = new URLSearchParams({ currency: ccy });
|
||||
const coin = coinSel ? coinSel.value : 'LTC';
|
||||
const params = new URLSearchParams({ coin: coin, currency: ccy });
|
||||
if (gelRateInput && gelRateInput.value) params.set('gel_rate', gelRateInput.value);
|
||||
rateStatus.textContent = ' fetching…';
|
||||
fetch('/admin/ltc-rate?' + params.toString(), { headers: { Accept: 'application/json' } })
|
||||
fetch('/admin/crypto-rate?' + params.toString(), { headers: { Accept: 'application/json' } })
|
||||
.then((r) => r.json().then((j) => ({ ok: r.ok, j })))
|
||||
.then(({ ok, j }) => {
|
||||
if (!ok) { rateStatus.textContent = ' ' + (j.error || 'failed'); return; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -83,28 +83,39 @@
|
|||
<textarea id="notes" name="notes" rows="3" maxlength="1000"><%= @form_values[:notes] %></textarea>
|
||||
</div>
|
||||
|
||||
<h2>Litecoin payment <span class="optional">(optional)</span></h2>
|
||||
<p class="optional" style="margin-top:-6px;">Leave the address blank to omit the LTC block and QR from the invoice.</p>
|
||||
<div class="field">
|
||||
<label for="ltc_address">LTC address</label>
|
||||
<input type="text" id="ltc_address" name="ltc_address"
|
||||
value="<%= @form_values[:ltc_address] || ENV['LTC_ADDRESS'] %>"
|
||||
placeholder="ltc1..." maxlength="90" />
|
||||
<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] || CryptoAsset.default_addresses[@form_values[:crypto_coin] || 'LTC'] %>"
|
||||
placeholder="payout address" maxlength="128" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="invoice-grid">
|
||||
<div class="field">
|
||||
<label for="ltc_rate">LTC rate (1 LTC in <span id="ltc-rate-ccy">currency</span>)</label>
|
||||
<input type="text" id="ltc_rate" name="ltc_rate" value="<%= @form_values[:ltc_rate] %>"
|
||||
<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] %>"
|
||||
placeholder="e.g. 85.20" inputmode="decimal" />
|
||||
<button type="button" id="fetch-ltc-rate" class="link-button">↻ Fetch live</button>
|
||||
<span id="ltc-rate-status" class="optional"></span>
|
||||
<button type="button" id="fetch-crypto-rate" class="link-button">↻ Fetch live</button>
|
||||
<span id="crypto-rate-status" class="optional"></span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ltc_amount">LTC amount due <span class="optional">(auto from rate; editable)</span></label>
|
||||
<input type="text" id="ltc_amount" name="ltc_amount" value="<%= @form_values[:ltc_amount] %>"
|
||||
<label for="crypto_amount">Amount due <span class="optional">(auto from rate; editable)</span></label>
|
||||
<input type="text" id="crypto_amount" name="crypto_amount" value="<%= @form_values[:crypto_amount] %>"
|
||||
placeholder="auto" inputmode="decimal" />
|
||||
</div>
|
||||
</div>
|
||||
<script type="application/json" id="crypto-defaults"><%== CryptoAsset.default_addresses.to_json %></script>
|
||||
|
||||
<button type="submit">Create invoice</button>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@
|
|||
<% if @invoice.paid_at %>
|
||||
<dt>Paid at</dt><dd class="mono"><%= @invoice.paid_at.utc.strftime('%Y-%m-%d %H:%M UTC') %></dd>
|
||||
<% end %>
|
||||
<% if @invoice.ltc? %>
|
||||
<dt>LTC</dt>
|
||||
<% if @invoice.crypto? %>
|
||||
<dt><%= @invoice.crypto_coin %></dt>
|
||||
<dd class="mono">
|
||||
<% if (amt = @invoice.ltc_amount_due) %><%= amt.to_s('F').sub(/\.?0+$/, '') %> LTC<% if @invoice.ltc_rate %> @ <%= @invoice.ltc_rate.to_s('F').sub(/(\.\d*?)0+$/, '\1').sub(/\.$/, '') %> <%= @invoice.currency %><% end %><br /><% end %>
|
||||
<span style="word-break:break-all;"><%= @invoice.ltc_address %></span>
|
||||
<% if (amt = @invoice.crypto_amount_due) %><%= fmt_crypto(amt) %> <%= @invoice.crypto_coin %><% if @invoice.crypto_rate %> @ <%= fmt_rate(@invoice.crypto_rate) %> <%= @invoice.currency %><% end %><br /><% end %>
|
||||
<span style="word-break:break-all;"><%= @invoice.crypto_address %></span>
|
||||
</dd>
|
||||
<% end %>
|
||||
</dl>
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@
|
|||
<% unless @invoice.currency == 'GEL' %>
|
||||
<dt>In GEL</dt><dd class="mono">GEL <%= '%.2f' % @invoice.total_gel %> <span class="optional">@ <%= fmt_rate(@invoice.gel_rate) %></span></dd>
|
||||
<% end %>
|
||||
<% if @invoice.ltc? && (ltc = @invoice.ltc_amount_due) %>
|
||||
<dt>In LTC</dt><dd class="mono"><%= fmt_ltc(ltc) %> LTC<% if @invoice.ltc_rate %> <span class="optional">@ <%= fmt_rate(@invoice.ltc_rate) %> <%= @invoice.currency %></span><% end %></dd>
|
||||
<% if @invoice.crypto? && (amt = @invoice.crypto_amount_due) %>
|
||||
<dt>In <%= @invoice.crypto_coin %></dt><dd class="mono"><%= fmt_crypto(amt) %> <%= @invoice.crypto_coin %><% if @invoice.crypto_rate %> <span class="optional">@ <%= fmt_rate(@invoice.crypto_rate) %> <%= @invoice.currency %></span><% end %></dd>
|
||||
<% end %>
|
||||
<dt>Status</dt><dd><span class="badge badge-<%= @invoice.status %>"><%= @invoice.status %></span></dd>
|
||||
</dl>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue