ltc payments

This commit is contained in:
Sergei Poljanski 2026-06-08 18:43:01 +03:00
commit c9bcbe7ff5
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
12 changed files with 338 additions and 2 deletions

View file

@ -35,6 +35,19 @@ class Invoice < Sequel::Model(:invoices)
(total * BigDecimal(gel_rate.to_s)).round(2)
end
def ltc?
!ltc_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)
end
class << self
def allocate_number(year = Date.today.year)
prefix = "INV-#{year}-"
@ -64,12 +77,37 @@ class Invoice < Sequel::Model(:invoices)
pdf_key: '',
created_at: Time.now.utc
)
apply_ltc(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
return if address.empty?
invoice.ltc_address = address
rate = decimal_or_nil(params[:ltc_rate])
invoice.ltc_rate = rate
amount = decimal_or_nil(params[:ltc_amount])
amount ||= (subtotal / rate).round(8) if rate && !rate.zero?
invoice.ltc_amount = amount
end
def decimal_or_nil(raw)
s = raw.to_s.strip
return nil if s.empty?
BigDecimal(s)
rescue ArgumentError
nil
end
def normalize_currency(c)
c = c.to_s.upcase
CURRENCIES.include?(c) or raise ArgumentError, "Unsupported currency: #{c}"

View file

@ -1,6 +1,7 @@
require 'prawn'
require 'prawn/table'
require 'bigdecimal'
require 'stringio'
class InvoicePdf
FONTS_DIR = File.join($root, 'public', 'fonts')
@ -233,6 +234,9 @@ class InvoicePdf
pdf.text "SWIFT: #{ISSUER[:swift]}"
pdf.text "Beneficiary: #{ISSUER[:name_latin]}"
end
draw_ltc(pdf) if @invoice.respond_to?(:ltc?) && @invoice.ltc?
unless @invoice.notes.to_s.strip.empty?
pdf.move_down 14
pdf.fill_color COLOR_MUTED
@ -243,7 +247,51 @@ class InvoicePdf
end
end
def draw_ltc(pdf)
amount = @invoice.ltc_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.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)
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
pdf.image StringIO.new(qr_bytes), width: qr_size, height: qr_size
end
end
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.fill_color COLOR_MUTED
pdf.text "(rate 1 LTC = #{fmt_money(@invoice.ltc_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.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.' }
pdf.fill_color COLOR_TEXT
end
end
end
end
def draw_footer(pdf)
# Static left-side line repeats on every page.
pdf.repeat(:all) do
pdf.canvas do
pdf.fill_color COLOR_MUTED
@ -252,17 +300,32 @@ class InvoicePdf
"#{ISSUER[:name_latin]} · Tax ID #{ISSUER[:tax_id]} · Small Business (1% turnover tax)",
at: [50, 25]
)
pdf.draw_text("Page #{pdf.page_number}", at: [pdf.bounds.right - 50, 25])
end
pdf.fill_color COLOR_TEXT
end
end
# Page number must be stamped per page: inside repeat(:all), pdf.page_number
# resolves to the final page count for every page. number_pages substitutes
# <page>/<total> at finalization, once per actual page.
pdf.number_pages(
'Page <page> of <total>',
at: [pdf.bounds.right - 90, -25],
width: 90,
align: :right,
size: 8,
color: COLOR_MUTED
)
end
def fmt_money(value)
BigDecimal(value.to_s).round(2).to_s('F').then { |s| with_thousands(s) }
end
def fmt_ltc(value)
BigDecimal(value.to_s).round(8).to_s('F').sub(/(\.\d*?)0+$/, '\\1').sub(/\.$/, '')
end
def fmt_qty(value)
bd = BigDecimal(value.to_s)
bd.frac.zero? ? bd.to_i.to_s : bd.to_s('F')

30
lib/ltc_qr.rb Normal file
View file

@ -0,0 +1,30 @@
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

57
lib/ltc_rate.rb Normal file
View file

@ -0,0 +1,57 @@
require 'net/http'
require 'json'
require 'uri'
require 'bigdecimal'
# Fetches the current Litecoin price 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
ENDPOINT = 'https://api.coingecko.com/api/v3/simple/price'.freeze
SUPPORTED = %w[USD EUR].freeze
TIMEOUT = 6 # seconds
class Error < StandardError; end
module_function
# Returns a BigDecimal: the price of 1 LTC in `currency`.
# `gel_rate` (USD->GEL) is required only when currency == 'GEL'.
def fetch(currency, gel_rate: nil)
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')
(usd * BigDecimal(gel_rate.to_s)).round(8)
elsif SUPPORTED.include?(currency)
fetch_simple(currency.downcase).round(8)
else
raise Error, "Unsupported currency: #{currency}"
end
end
def fetch_simple(vs)
uri = URI(ENDPOINT)
uri.query = URI.encode_www_form(ids: 'litecoin', vs_currencies: vs)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
http.get(uri.request_uri, 'Accept' => 'application/json')
end
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?
BigDecimal(price.to_s)
rescue JSON::ParserError => e
raise Error, "Bad JSON from CoinGecko: #{e.message}"
rescue Net::OpenTimeout, Net::ReadTimeout
raise Error, 'CoinGecko timed out'
end
end