diff --git a/.gitignore b/.gitignore index 48642aa..03e5e3d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ vendor/bundle/ *.log tmp/ -logo/ \ No newline at end of file +logo/ +SECRETS.md \ No newline at end of file diff --git a/Gemfile b/Gemfile index 49677cd..ee16b59 100644 --- a/Gemfile +++ b/Gemfile @@ -16,6 +16,8 @@ gem 'pg', '~> 1.5' gem 'sequel', '~> 5.86' gem 'prawn', '~> 2.5' gem 'prawn-table', '~> 0.2' +gem 'rqrcode', '~> 2.2' +gem 'chunky_png', '~> 1.4' gem 'aws-sdk-s3', '~> 1.170' group :development do diff --git a/Gemfile.lock b/Gemfile.lock index 5865388..7835f96 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -22,6 +22,7 @@ GEM aws-eventstream (~> 1, >= 1.0.2) base64 (0.3.0) bigdecimal (3.3.1) + chunky_png (1.4.0) date (3.5.1) dotenv (3.2.0) erubi (1.13.1) @@ -93,6 +94,10 @@ GEM ffi (~> 1.0) rerun (0.14.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) bigdecimal sinatra (4.2.1) @@ -128,6 +133,7 @@ PLATFORMS DEPENDENCIES aws-sdk-s3 (~> 1.170) + chunky_png (~> 1.4) dotenv (~> 3.1) erubi (~> 1.13) mail (~> 2.8) @@ -140,6 +146,7 @@ DEPENDENCIES rack-session (~> 2.1) rackup (~> 2.3) rerun (~> 0.14) + rqrcode (~> 2.2) sequel (~> 5.86) sinatra (~> 4.2) sinatra-contrib (~> 4.2) diff --git a/asxpio.rb b/asxpio.rb index a611490..c8a7107 100644 --- a/asxpio.rb +++ b/asxpio.rb @@ -26,6 +26,8 @@ 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/invoice_pdf' else $logger.warn('DATABASE_URL not set — invoicing disabled') @@ -152,6 +154,21 @@ 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 + 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 @page_title = 'New invoice — admin' @noindex = true @@ -170,6 +187,9 @@ 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, items: (params[:items] || {}).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? } errors[:items] = 'At least one line item with a description is required' if items.empty? 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 end end diff --git a/db/migrations/002_invoice_ltc.rb b/db/migrations/002_invoice_ltc.rb new file mode 100644 index 0000000..9c22877 --- /dev/null +++ b/db/migrations/002_invoice_ltc.rb @@ -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 diff --git a/lib/invoice.rb b/lib/invoice.rb index 4aebf8b..b5bdbe2 100644 --- a/lib/invoice.rb +++ b/lib/invoice.rb @@ -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}" diff --git a/lib/invoice_pdf.rb b/lib/invoice_pdf.rb index 363abb7..9883e99 100644 --- a/lib/invoice_pdf.rb +++ b/lib/invoice_pdf.rb @@ -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 + # / at finalization, once per actual page. + pdf.number_pages( + 'Page of ', + 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') diff --git a/lib/ltc_qr.rb b/lib/ltc_qr.rb new file mode 100644 index 0000000..0741381 --- /dev/null +++ b/lib/ltc_qr.rb @@ -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:
?amount= +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 diff --git a/lib/ltc_rate.rb b/lib/ltc_rate.rb new file mode 100644 index 0000000..53d5e27 --- /dev/null +++ b/lib/ltc_rate.rb @@ -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 diff --git a/public/admin-invoice-form.js b/public/admin-invoice-form.js index 39b2f1c..0287ab5 100644 --- a/public/admin-invoice-form.js +++ b/public/admin-invoice-form.js @@ -25,4 +25,64 @@ e.target.closest('tr.item-row').remove(); 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'; }); + }); + } })(); diff --git a/views/admin/invoices/new.erb b/views/admin/invoices/new.erb index 217a360..2208b22 100644 --- a/views/admin/invoices/new.erb +++ b/views/admin/invoices/new.erb @@ -81,6 +81,29 @@ +

Litecoin payment (optional)

+

Leave the address blank to omit the LTC block and QR from the invoice.

+
+ + +
+
+
+ + + + +
+
+ + +
+
+ diff --git a/views/admin/invoices/show.erb b/views/admin/invoices/show.erb index c3883b5..463adcc 100644 --- a/views/admin/invoices/show.erb +++ b/views/admin/invoices/show.erb @@ -13,6 +13,13 @@ <% if @invoice.paid_at %>
Paid at
<%= @invoice.paid_at.utc.strftime('%Y-%m-%d %H:%M UTC') %>
<% end %> + <% if @invoice.ltc? %> +
LTC
+
+ <% if (amt = @invoice.ltc_amount_due) %><%= amt.to_s('F').sub(/\.?0+$/, '') %> LTC<% if @invoice.ltc_rate %> @ <%= '%.2f' % @invoice.ltc_rate %> <%= @invoice.currency %><% end %>
<% end %> + <%= @invoice.ltc_address %> +
+ <% end %>