From 7dbeb0dd4982bfed44a785e34c5ad67fc0138036 Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Mon, 8 Jun 2026 19:29:25 +0300 Subject: [PATCH] nbg and auto fill GEL exchange rate --- asxpio.rb | 39 ++++++++++++++++++++++ lib/gel_rate.rb | 61 +++++++++++++++++++++++++++++++++++ lib/invoice_pdf.rb | 15 +++++++-- public/admin-invoice-form.js | 39 +++++++++++++++++++++- views/admin/invoices/new.erb | 4 ++- views/admin/invoices/show.erb | 2 +- views/invoice_public.erb | 6 ++++ 7 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 lib/gel_rate.rb diff --git a/asxpio.rb b/asxpio.rb index 56a4652..e3cf433 100644 --- a/asxpio.rb +++ b/asxpio.rb @@ -28,6 +28,7 @@ if ENV['DATABASE_URL'] require_relative 'lib/invoice' require_relative 'lib/ltc_rate' require_relative 'lib/ltc_qr' + require_relative 'lib/gel_rate' require_relative 'lib/invoice_pdf' else $logger.warn('DATABASE_URL not set — invoicing disabled') @@ -65,6 +66,20 @@ class AsxpioWeb < Sinatra::Base request.env['HTTP_X_REAL_IP'] || request.ip end + + # Exchange rate for display: full captured precision (up to 8 dp), trailing + # zeros trimmed, padded to at least min_dp. Mirrors InvoicePdf#fmt_rate so + # the HTML and PDF agree. (4 dp matches the NBG's GEL quoting.) + def fmt_rate(value, min_dp = 4) + frac = BigDecimal(value.to_s).round(8).to_s('F').split('.').last.sub(/0+$/, '') + whole = BigDecimal(value.to_s).to_i + "#{whole}.#{frac.ljust(min_dp, '0')}" + end + + # LTC amount: up to 8 dp, trailing zeros trimmed, no padding. + def fmt_ltc(value) + BigDecimal(value.to_s).round(8).to_s('F').sub(/(\.\d*?)0+$/, '\1').sub(/\.$/, '') + end end before do @@ -169,6 +184,30 @@ class AsxpioWeb < Sinatra::Base end end + # Official GEL rate from the National Bank of Georgia for the new-invoice + # form's "Fetch official" button. The rate is GEL per 1 unit of the invoice + # currency (USD/EUR), so it tracks the form's selected currency. Pass date= + # for a past invoice (NBG rolls back to the last published rate on + # weekends/holidays). GEL invoices have a trivial rate of 1 — no fetch needed. + get '/admin/gel-rate' do + content_type :json + currency = params[:currency].to_s.upcase + currency = 'USD' if currency.empty? + date = params[:date].to_s.strip + + if currency == 'GEL' + return { rate: '1', currency: 'GEL', date: date }.to_json + end + + begin + rate = GelRate.fetch(currency, date: date.empty? ? nil : date) + { rate: rate.to_s('F').sub(/(\.\d*?)0+$/, '\1').sub(/\.$/, ''), currency: currency, date: date }.to_json + rescue GelRate::Error => e + status 502 + { error: e.message }.to_json + end + end + get '/admin/invoices/new' do @page_title = 'New invoice — admin' @noindex = true diff --git a/lib/gel_rate.rb b/lib/gel_rate.rb new file mode 100644 index 0000000..137ca67 --- /dev/null +++ b/lib/gel_rate.rb @@ -0,0 +1,61 @@ +require 'net/http' +require 'json' +require 'uri' +require 'date' +require 'bigdecimal' + +# Fetches the official GEL exchange rate from the National Bank of Georgia. +# https://nbg.gov.ge/en/monetary-policy/currency +# +# API: GET .../currencies/en/json/?currencies=USD&date=YYYY-MM-DD +# Response is a top-level array; rate is quoted per `quantity` units, so the +# per-unit rate is rate / quantity. NBG returns the rate valid on or before the +# requested date (weekends/holidays roll back to the last published rate). +module GelRate + ENDPOINT = 'https://nbg.gov.ge/gw/api/ct/monetarypolicy/currencies/en/json/'.freeze + TIMEOUT = 6 # seconds + + class Error < StandardError; end + + module_function + + # Returns a BigDecimal: how many GEL per 1 unit of `currency` on `date`. + # `date` may be a Date or YYYY-MM-DD string; omitted ⇒ latest published rate. + def fetch(currency = 'USD', date: nil) + currency = currency.to_s.upcase + uri = URI(ENDPOINT) + query = { currencies: currency } + query[:date] = normalize_date(date) if date + uri.query = URI.encode_www_form(query) + + 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, "NBG HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess) + + data = JSON.parse(res.body) + entry = Array(data).first or raise Error, 'Empty NBG response' + cur = Array(entry['currencies']).find { |c| c['code'] == currency } \ + or raise Error, "No #{currency} in NBG response" + + rate = BigDecimal(cur.fetch('rate').to_s) + qty = BigDecimal(cur.fetch('quantity', 1).to_s) + raise Error, 'NBG quantity is zero' if qty.zero? + + (rate / qty).round(8) + rescue JSON::ParserError => e + raise Error, "Bad JSON from NBG: #{e.message}" + rescue Net::OpenTimeout, Net::ReadTimeout + raise Error, 'NBG timed out' + rescue KeyError => e + raise Error, "Missing field in NBG response: #{e.message}" + end + + def normalize_date(date) + d = date.is_a?(Date) ? date : Date.parse(date.to_s) + d.strftime('%Y-%m-%d') + rescue ArgumentError + raise Error, "Invalid date: #{date}" + end +end diff --git a/lib/invoice_pdf.rb b/lib/invoice_pdf.rb index 3dcdfdc..2dcb751 100644 --- a/lib/invoice_pdf.rb +++ b/lib/invoice_pdf.rb @@ -210,7 +210,7 @@ class InvoicePdf ['Total', "#{@invoice.currency} #{fmt_money(total)}"] ] if @invoice.currency != 'GEL' - rows << ["In GEL (rate #{fmt_money(@invoice.gel_rate)})", "GEL #{fmt_money(@invoice.total_gel)}"] + rows << ["In GEL (rate #{fmt_rate(@invoice.gel_rate)})", "GEL #{fmt_money(@invoice.total_gel)}"] end pdf.bounding_box([pdf.bounds.right - 270, pdf.cursor], width: 270) do @@ -277,7 +277,7 @@ class InvoicePdf 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.text "(rate 1 LTC = #{fmt_rate(@invoice.ltc_rate)} #{@invoice.currency} at issue)", size: 8 pdf.fill_color COLOR_TEXT end pdf.move_down 3 @@ -329,6 +329,17 @@ class InvoicePdf BigDecimal(value.to_s).round(8).to_s('F').sub(/(\.\d*?)0+$/, '\\1').sub(/\.$/, '') end + # Exchange rate: keep full captured precision (up to 8 dp), trim trailing + # zeros, but pad to at least `min_dp` so it reads as a rate. GEL/USD rates are + # quoted to 4 dp by the NBG, so the default min is 4. + def fmt_rate(value, min_dp: 4) + s = BigDecimal(value.to_s).round(8).to_s('F') + int, frac = s.split('.') + frac = (frac || '').sub(/0+$/, '') + frac = frac.ljust(min_dp, '0') + "#{with_thousands("#{int}.00").split('.').first}.#{frac}" + 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/public/admin-invoice-form.js b/public/admin-invoice-form.js index 0287ab5..4219335 100644 --- a/public/admin-invoice-form.js +++ b/public/admin-invoice-form.js @@ -59,8 +59,11 @@ } } + const gelRateCcy = document.getElementById('gel-rate-ccy'); function syncCcyLabel() { - if (rateCcy && currencySel) rateCcy.textContent = currencySel.value; + const ccy = currencySel ? currencySel.value : 'USD'; + if (rateCcy) rateCcy.textContent = ccy; + if (gelRateCcy) gelRateCcy.textContent = ccy; } syncCcyLabel(); if (currencySel) currencySel.addEventListener('change', syncCcyLabel); @@ -85,4 +88,38 @@ .catch(() => { rateStatus.textContent = ' network error'; }); }); } + + // --- Official GEL rate from NBG ------------------------------------- + const gelFetchBtn = document.getElementById('fetch-gel-rate'); + const gelStatus = document.getElementById('gel-rate-status'); + const issuedInput = document.getElementById('issued_on'); + + if (gelFetchBtn) { + gelFetchBtn.addEventListener('click', function () { + const ccy = currencySel ? currencySel.value : 'USD'; + const params = new URLSearchParams({ currency: ccy }); + // Past invoice ⇒ fetch the rate as published for the issued date. + if (issuedInput && issuedInput.value && issuedInput.value < today()) { + params.set('date', issuedInput.value); + } + gelStatus.textContent = ' fetching…'; + fetch('/admin/gel-rate?' + params.toString(), { headers: { Accept: 'application/json' } }) + .then((r) => r.json().then((j) => ({ ok: r.ok, j }))) + .then(({ ok, j }) => { + if (!ok) { gelStatus.textContent = ' ' + (j.error || 'failed'); return; } + if (gelRateInput) gelRateInput.value = j.rate; + gelStatus.textContent = params.has('date') + ? ' ✓ official ' + params.get('date') + : ' ✓ official (latest)'; + }) + .catch(() => { gelStatus.textContent = ' network error'; }); + }); + } + + function today() { + const d = new Date(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return d.getFullYear() + '-' + m + '-' + day; + } })(); diff --git a/views/admin/invoices/new.erb b/views/admin/invoices/new.erb index 2208b22..a3b15de 100644 --- a/views/admin/invoices/new.erb +++ b/views/admin/invoices/new.erb @@ -48,8 +48,10 @@
- + + +
diff --git a/views/admin/invoices/show.erb b/views/admin/invoices/show.erb index 463adcc..dfd25e7 100644 --- a/views/admin/invoices/show.erb +++ b/views/admin/invoices/show.erb @@ -16,7 +16,7 @@ <% 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 %> + <% 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 %>
<% end %> <%= @invoice.ltc_address %>
<% end %> diff --git a/views/invoice_public.erb b/views/invoice_public.erb index 07f4ae4..dec0191 100644 --- a/views/invoice_public.erb +++ b/views/invoice_public.erb @@ -9,6 +9,12 @@
Issued
<%= @invoice.issued_on.strftime('%Y-%m-%d') %>
Due
<%= @invoice.due_on.strftime('%Y-%m-%d') %>
Total
<%= @invoice.currency %> <%= '%.2f' % @invoice.total %>
+ <% unless @invoice.currency == 'GEL' %> +
In GEL
GEL <%= '%.2f' % @invoice.total_gel %> @ <%= fmt_rate(@invoice.gel_rate) %>
+ <% end %> + <% if @invoice.ltc? && (ltc = @invoice.ltc_amount_due) %> +
In LTC
<%= fmt_ltc(ltc) %> LTC<% if @invoice.ltc_rate %> @ <%= fmt_rate(@invoice.ltc_rate) %> <%= @invoice.currency %><% end %>
+ <% end %>
Status
<%= @invoice.status %>