ltc payments
This commit is contained in:
parent
8f3afcfcd2
commit
c9bcbe7ff5
12 changed files with 338 additions and 2 deletions
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
30
lib/ltc_qr.rb
Normal 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
57
lib/ltc_rate.rb
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue