ltc payments
This commit is contained in:
parent
8f3afcfcd2
commit
c9bcbe7ff5
12 changed files with 338 additions and 2 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -4,4 +4,5 @@
|
||||||
vendor/bundle/
|
vendor/bundle/
|
||||||
*.log
|
*.log
|
||||||
tmp/
|
tmp/
|
||||||
logo/
|
logo/
|
||||||
|
SECRETS.md
|
||||||
2
Gemfile
2
Gemfile
|
|
@ -16,6 +16,8 @@ gem 'pg', '~> 1.5'
|
||||||
gem 'sequel', '~> 5.86'
|
gem 'sequel', '~> 5.86'
|
||||||
gem 'prawn', '~> 2.5'
|
gem 'prawn', '~> 2.5'
|
||||||
gem 'prawn-table', '~> 0.2'
|
gem 'prawn-table', '~> 0.2'
|
||||||
|
gem 'rqrcode', '~> 2.2'
|
||||||
|
gem 'chunky_png', '~> 1.4'
|
||||||
gem 'aws-sdk-s3', '~> 1.170'
|
gem 'aws-sdk-s3', '~> 1.170'
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ GEM
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bigdecimal (3.3.1)
|
bigdecimal (3.3.1)
|
||||||
|
chunky_png (1.4.0)
|
||||||
date (3.5.1)
|
date (3.5.1)
|
||||||
dotenv (3.2.0)
|
dotenv (3.2.0)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
|
|
@ -93,6 +94,10 @@ GEM
|
||||||
ffi (~> 1.0)
|
ffi (~> 1.0)
|
||||||
rerun (0.14.0)
|
rerun (0.14.0)
|
||||||
listen (~> 3.0)
|
listen (~> 3.0)
|
||||||
|
rqrcode (2.2.0)
|
||||||
|
chunky_png (~> 1.0)
|
||||||
|
rqrcode_core (~> 1.0)
|
||||||
|
rqrcode_core (1.2.0)
|
||||||
sequel (5.104.0)
|
sequel (5.104.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
sinatra (4.2.1)
|
sinatra (4.2.1)
|
||||||
|
|
@ -128,6 +133,7 @@ PLATFORMS
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
aws-sdk-s3 (~> 1.170)
|
aws-sdk-s3 (~> 1.170)
|
||||||
|
chunky_png (~> 1.4)
|
||||||
dotenv (~> 3.1)
|
dotenv (~> 3.1)
|
||||||
erubi (~> 1.13)
|
erubi (~> 1.13)
|
||||||
mail (~> 2.8)
|
mail (~> 2.8)
|
||||||
|
|
@ -140,6 +146,7 @@ DEPENDENCIES
|
||||||
rack-session (~> 2.1)
|
rack-session (~> 2.1)
|
||||||
rackup (~> 2.3)
|
rackup (~> 2.3)
|
||||||
rerun (~> 0.14)
|
rerun (~> 0.14)
|
||||||
|
rqrcode (~> 2.2)
|
||||||
sequel (~> 5.86)
|
sequel (~> 5.86)
|
||||||
sinatra (~> 4.2)
|
sinatra (~> 4.2)
|
||||||
sinatra-contrib (~> 4.2)
|
sinatra-contrib (~> 4.2)
|
||||||
|
|
|
||||||
33
asxpio.rb
33
asxpio.rb
|
|
@ -26,6 +26,8 @@ if ENV['DATABASE_URL']
|
||||||
DB.connect!
|
DB.connect!
|
||||||
DB.migrate!
|
DB.migrate!
|
||||||
require_relative 'lib/invoice'
|
require_relative 'lib/invoice'
|
||||||
|
require_relative 'lib/ltc_rate'
|
||||||
|
require_relative 'lib/ltc_qr'
|
||||||
require_relative 'lib/invoice_pdf'
|
require_relative 'lib/invoice_pdf'
|
||||||
else
|
else
|
||||||
$logger.warn('DATABASE_URL not set — invoicing disabled')
|
$logger.warn('DATABASE_URL not set — invoicing disabled')
|
||||||
|
|
@ -152,6 +154,21 @@ class AsxpioWeb < Sinatra::Base
|
||||||
erb :'admin/invoices/index'
|
erb :'admin/invoices/index'
|
||||||
end
|
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
|
||||||
|
content_type :json
|
||||||
|
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
|
||||||
|
status 502
|
||||||
|
{ error: e.message }.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
get '/admin/invoices/new' do
|
get '/admin/invoices/new' do
|
||||||
@page_title = 'New invoice — admin'
|
@page_title = 'New invoice — admin'
|
||||||
@noindex = true
|
@noindex = true
|
||||||
|
|
@ -170,6 +187,9 @@ class AsxpioWeb < Sinatra::Base
|
||||||
issued_on: params[:issued_on].to_s.strip,
|
issued_on: params[:issued_on].to_s.strip,
|
||||||
due_on: params[:due_on].to_s.strip,
|
due_on: params[:due_on].to_s.strip,
|
||||||
notes: params[:notes].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,
|
||||||
items: (params[:items] || {}).values
|
items: (params[:items] || {}).values
|
||||||
}
|
}
|
||||||
@form_errors = validate_invoice_params(@form_values)
|
@form_errors = validate_invoice_params(@form_values)
|
||||||
|
|
@ -247,6 +267,19 @@ class AsxpioWeb < Sinatra::Base
|
||||||
items = v[:items].select { |i| i.is_a?(Hash) && !i['description'].to_s.strip.empty? }
|
items = v[:items].select { |i| i.is_a?(Hash) && !i['description'].to_s.strip.empty? }
|
||||||
errors[:items] = 'At least one line item with a description is required' if items.empty?
|
errors[:items] = 'At least one line item with a description is required' if items.empty?
|
||||||
v[:items] = items
|
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|
|
||||||
|
next if v[k].to_s.strip.empty?
|
||||||
|
begin
|
||||||
|
raise ArgumentError if BigDecimal(v[k]) <= 0
|
||||||
|
rescue ArgumentError
|
||||||
|
errors[k] = "#{k.to_s.tr('_', ' ').capitalize} must be a positive decimal"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
errors
|
errors
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
15
db/migrations/002_invoice_ltc.rb
Normal file
15
db/migrations/002_invoice_ltc.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
Sequel.migration do
|
||||||
|
change do
|
||||||
|
alter_table(:invoices) do
|
||||||
|
# LTC payout address captured at issue (defaults from LTC_ADDRESS env in the form).
|
||||||
|
# All three are nullable: LTC payment is opt-in per invoice.
|
||||||
|
add_column :ltc_address, String
|
||||||
|
# LTC price in the invoice currency at issue time (snapshot, like gel_rate).
|
||||||
|
# May be hand-entered or fetched from CoinGecko at form time.
|
||||||
|
add_column :ltc_rate, BigDecimal, size: [18, 8]
|
||||||
|
# LTC amount due. Normally total / ltc_rate, but can be hand-overridden,
|
||||||
|
# so it is stored explicitly rather than recomputed.
|
||||||
|
add_column :ltc_amount, BigDecimal, size: [18, 8]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -35,6 +35,19 @@ class Invoice < Sequel::Model(:invoices)
|
||||||
(total * BigDecimal(gel_rate.to_s)).round(2)
|
(total * BigDecimal(gel_rate.to_s)).round(2)
|
||||||
end
|
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
|
class << self
|
||||||
def allocate_number(year = Date.today.year)
|
def allocate_number(year = Date.today.year)
|
||||||
prefix = "INV-#{year}-"
|
prefix = "INV-#{year}-"
|
||||||
|
|
@ -64,12 +77,37 @@ class Invoice < Sequel::Model(:invoices)
|
||||||
pdf_key: '',
|
pdf_key: '',
|
||||||
created_at: Time.now.utc
|
created_at: Time.now.utc
|
||||||
)
|
)
|
||||||
|
apply_ltc(invoice, params, subtotal)
|
||||||
invoice.uuid = SecureRandom.uuid
|
invoice.uuid = SecureRandom.uuid
|
||||||
invoice
|
invoice
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
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)
|
def normalize_currency(c)
|
||||||
c = c.to_s.upcase
|
c = c.to_s.upcase
|
||||||
CURRENCIES.include?(c) or raise ArgumentError, "Unsupported currency: #{c}"
|
CURRENCIES.include?(c) or raise ArgumentError, "Unsupported currency: #{c}"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
require 'prawn'
|
require 'prawn'
|
||||||
require 'prawn/table'
|
require 'prawn/table'
|
||||||
require 'bigdecimal'
|
require 'bigdecimal'
|
||||||
|
require 'stringio'
|
||||||
|
|
||||||
class InvoicePdf
|
class InvoicePdf
|
||||||
FONTS_DIR = File.join($root, 'public', 'fonts')
|
FONTS_DIR = File.join($root, 'public', 'fonts')
|
||||||
|
|
@ -233,6 +234,9 @@ class InvoicePdf
|
||||||
pdf.text "SWIFT: #{ISSUER[:swift]}"
|
pdf.text "SWIFT: #{ISSUER[:swift]}"
|
||||||
pdf.text "Beneficiary: #{ISSUER[:name_latin]}"
|
pdf.text "Beneficiary: #{ISSUER[:name_latin]}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
draw_ltc(pdf) if @invoice.respond_to?(:ltc?) && @invoice.ltc?
|
||||||
|
|
||||||
unless @invoice.notes.to_s.strip.empty?
|
unless @invoice.notes.to_s.strip.empty?
|
||||||
pdf.move_down 14
|
pdf.move_down 14
|
||||||
pdf.fill_color COLOR_MUTED
|
pdf.fill_color COLOR_MUTED
|
||||||
|
|
@ -243,7 +247,51 @@ class InvoicePdf
|
||||||
end
|
end
|
||||||
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)
|
def draw_footer(pdf)
|
||||||
|
# Static left-side line repeats on every page.
|
||||||
pdf.repeat(:all) do
|
pdf.repeat(:all) do
|
||||||
pdf.canvas do
|
pdf.canvas do
|
||||||
pdf.fill_color COLOR_MUTED
|
pdf.fill_color COLOR_MUTED
|
||||||
|
|
@ -252,17 +300,32 @@ class InvoicePdf
|
||||||
"#{ISSUER[:name_latin]} · Tax ID #{ISSUER[:tax_id]} · Small Business (1% turnover tax)",
|
"#{ISSUER[:name_latin]} · Tax ID #{ISSUER[:tax_id]} · Small Business (1% turnover tax)",
|
||||||
at: [50, 25]
|
at: [50, 25]
|
||||||
)
|
)
|
||||||
pdf.draw_text("Page #{pdf.page_number}", at: [pdf.bounds.right - 50, 25])
|
|
||||||
end
|
end
|
||||||
pdf.fill_color COLOR_TEXT
|
pdf.fill_color COLOR_TEXT
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
def fmt_money(value)
|
def fmt_money(value)
|
||||||
BigDecimal(value.to_s).round(2).to_s('F').then { |s| with_thousands(s) }
|
BigDecimal(value.to_s).round(2).to_s('F').then { |s| with_thousands(s) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def fmt_ltc(value)
|
||||||
|
BigDecimal(value.to_s).round(8).to_s('F').sub(/(\.\d*?)0+$/, '\\1').sub(/\.$/, '')
|
||||||
|
end
|
||||||
|
|
||||||
def fmt_qty(value)
|
def fmt_qty(value)
|
||||||
bd = BigDecimal(value.to_s)
|
bd = BigDecimal(value.to_s)
|
||||||
bd.frac.zero? ? bd.to_i.to_s : bd.to_s('F')
|
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
|
||||||
|
|
@ -25,4 +25,64 @@
|
||||||
e.target.closest('tr.item-row').remove();
|
e.target.closest('tr.item-row').remove();
|
||||||
reindex();
|
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');
|
||||||
|
const currencySel = document.getElementById('currency');
|
||||||
|
const gelRateInput = document.getElementById('gel_rate');
|
||||||
|
|
||||||
|
function invoiceTotal() {
|
||||||
|
let total = 0;
|
||||||
|
tbody.querySelectorAll('tr.item-row').forEach((row) => {
|
||||||
|
const inputs = row.querySelectorAll('input');
|
||||||
|
const qty = parseFloat(inputs[1].value) || 0;
|
||||||
|
const unit = parseFloat(inputs[2].value) || 0;
|
||||||
|
total += qty * unit;
|
||||||
|
});
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the operator has hand-edited the amount; if so, don't clobber it.
|
||||||
|
let amountTouched = false;
|
||||||
|
if (amountInput) amountInput.addEventListener('input', () => { amountTouched = true; });
|
||||||
|
|
||||||
|
function recomputeAmount() {
|
||||||
|
if (!amountInput || amountTouched) return;
|
||||||
|
const rate = parseFloat(rateInput.value);
|
||||||
|
const total = invoiceTotal();
|
||||||
|
if (rate > 0 && total > 0) {
|
||||||
|
amountInput.value = (total / rate).toFixed(8).replace(/\.?0+$/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCcyLabel() {
|
||||||
|
if (rateCcy && currencySel) rateCcy.textContent = currencySel.value;
|
||||||
|
}
|
||||||
|
syncCcyLabel();
|
||||||
|
if (currencySel) currencySel.addEventListener('change', syncCcyLabel);
|
||||||
|
|
||||||
|
if (rateInput) rateInput.addEventListener('input', recomputeAmount);
|
||||||
|
tbody.addEventListener('input', recomputeAmount);
|
||||||
|
|
||||||
|
if (fetchBtn) {
|
||||||
|
fetchBtn.addEventListener('click', function () {
|
||||||
|
const ccy = currencySel ? currencySel.value : 'USD';
|
||||||
|
const params = new URLSearchParams({ 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' } })
|
||||||
|
.then((r) => r.json().then((j) => ({ ok: r.ok, j })))
|
||||||
|
.then(({ ok, j }) => {
|
||||||
|
if (!ok) { rateStatus.textContent = ' ' + (j.error || 'failed'); return; }
|
||||||
|
rateInput.value = j.rate;
|
||||||
|
rateStatus.textContent = ' ✓ live';
|
||||||
|
recomputeAmount();
|
||||||
|
})
|
||||||
|
.catch(() => { rateStatus.textContent = ' network error'; });
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,29 @@
|
||||||
<textarea id="notes" name="notes" rows="3" maxlength="1000"><%= @form_values[:notes] %></textarea>
|
<textarea id="notes" name="notes" rows="3" maxlength="1000"><%= @form_values[:notes] %></textarea>
|
||||||
</div>
|
</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" />
|
||||||
|
</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] %>"
|
||||||
|
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>
|
||||||
|
</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] %>"
|
||||||
|
placeholder="auto" inputmode="decimal" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit">Create invoice</button>
|
<button type="submit">Create invoice</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,13 @@
|
||||||
<% if @invoice.paid_at %>
|
<% if @invoice.paid_at %>
|
||||||
<dt>Paid at</dt><dd class="mono"><%= @invoice.paid_at.utc.strftime('%Y-%m-%d %H:%M UTC') %></dd>
|
<dt>Paid at</dt><dd class="mono"><%= @invoice.paid_at.utc.strftime('%Y-%m-%d %H:%M UTC') %></dd>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% if @invoice.ltc? %>
|
||||||
|
<dt>LTC</dt>
|
||||||
|
<dd class="mono">
|
||||||
|
<% if (amt = @invoice.ltc_amount_due) %><%= amt.to_s('F').sub(/\.?0+$/, '') %> LTC<% if @invoice.ltc_rate %> @ <%= '%.2f' % @invoice.ltc_rate %> <%= @invoice.currency %><% end %><br /><% end %>
|
||||||
|
<span style="word-break:break-all;"><%= @invoice.ltc_address %></span>
|
||||||
|
</dd>
|
||||||
|
<% end %>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue