nbg and auto fill GEL exchange rate
All checks were successful
Build and Deploy to Production / build (push) Successful in 47s
Build and Deploy to Production / deploy (push) Successful in 27s

This commit is contained in:
Sergei Poljanski 2026-06-08 19:29:25 +03:00
commit 7dbeb0dd49
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
7 changed files with 161 additions and 5 deletions

View file

@ -28,6 +28,7 @@ if ENV['DATABASE_URL']
require_relative 'lib/invoice' require_relative 'lib/invoice'
require_relative 'lib/ltc_rate' require_relative 'lib/ltc_rate'
require_relative 'lib/ltc_qr' require_relative 'lib/ltc_qr'
require_relative 'lib/gel_rate'
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')
@ -65,6 +66,20 @@ class AsxpioWeb < Sinatra::Base
request.env['HTTP_X_REAL_IP'] || request.env['HTTP_X_REAL_IP'] ||
request.ip request.ip
end 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 end
before do before do
@ -169,6 +184,30 @@ class AsxpioWeb < Sinatra::Base
end end
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 get '/admin/invoices/new' do
@page_title = 'New invoice — admin' @page_title = 'New invoice — admin'
@noindex = true @noindex = true

61
lib/gel_rate.rb Normal file
View file

@ -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

View file

@ -210,7 +210,7 @@ class InvoicePdf
['Total', "#{@invoice.currency} #{fmt_money(total)}"] ['Total', "#{@invoice.currency} #{fmt_money(total)}"]
] ]
if @invoice.currency != 'GEL' 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 end
pdf.bounding_box([pdf.bounds.right - 270, pdf.cursor], width: 270) do 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 pdf.text "Amount: #{fmt_ltc(amount)} LTC", style: :bold
if @invoice.ltc_rate if @invoice.ltc_rate
pdf.fill_color COLOR_MUTED 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 pdf.fill_color COLOR_TEXT
end end
pdf.move_down 3 pdf.move_down 3
@ -329,6 +329,17 @@ class InvoicePdf
BigDecimal(value.to_s).round(8).to_s('F').sub(/(\.\d*?)0+$/, '\\1').sub(/\.$/, '') BigDecimal(value.to_s).round(8).to_s('F').sub(/(\.\d*?)0+$/, '\\1').sub(/\.$/, '')
end 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) 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')

View file

@ -59,8 +59,11 @@
} }
} }
const gelRateCcy = document.getElementById('gel-rate-ccy');
function syncCcyLabel() { 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(); syncCcyLabel();
if (currencySel) currencySel.addEventListener('change', syncCcyLabel); if (currencySel) currencySel.addEventListener('change', syncCcyLabel);
@ -85,4 +88,38 @@
.catch(() => { rateStatus.textContent = ' network error'; }); .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;
}
})(); })();

View file

@ -48,8 +48,10 @@
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label for="gel_rate">GEL rate</label> <label for="gel_rate">GEL rate (GEL per 1 <span id="gel-rate-ccy">unit</span>)</label>
<input type="text" id="gel_rate" name="gel_rate" value="<%= @form_values[:gel_rate] %>" placeholder="2.9500" inputmode="decimal" required /> <input type="text" id="gel_rate" name="gel_rate" value="<%= @form_values[:gel_rate] %>" placeholder="2.9500" inputmode="decimal" required />
<button type="button" id="fetch-gel-rate" class="link-button">↻ Fetch official (NBG)</button>
<span id="gel-rate-status" class="optional"></span>
</div> </div>
</div> </div>

View file

@ -16,7 +16,7 @@
<% if @invoice.ltc? %> <% if @invoice.ltc? %>
<dt>LTC</dt> <dt>LTC</dt>
<dd class="mono"> <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 %> <% 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 %><br /><% end %>
<span style="word-break:break-all;"><%= @invoice.ltc_address %></span> <span style="word-break:break-all;"><%= @invoice.ltc_address %></span>
</dd> </dd>
<% end %> <% end %>

View file

@ -9,6 +9,12 @@
<dt>Issued</dt><dd class="mono"><%= @invoice.issued_on.strftime('%Y-%m-%d') %></dd> <dt>Issued</dt><dd class="mono"><%= @invoice.issued_on.strftime('%Y-%m-%d') %></dd>
<dt>Due</dt><dd class="mono"><%= @invoice.due_on.strftime('%Y-%m-%d') %></dd> <dt>Due</dt><dd class="mono"><%= @invoice.due_on.strftime('%Y-%m-%d') %></dd>
<dt>Total</dt><dd class="mono"><%= @invoice.currency %> <%= '%.2f' % @invoice.total %></dd> <dt>Total</dt><dd class="mono"><%= @invoice.currency %> <%= '%.2f' % @invoice.total %></dd>
<% unless @invoice.currency == 'GEL' %>
<dt>In GEL</dt><dd class="mono">GEL <%= '%.2f' % @invoice.total_gel %> <span class="optional">@ <%= fmt_rate(@invoice.gel_rate) %></span></dd>
<% end %>
<% if @invoice.ltc? && (ltc = @invoice.ltc_amount_due) %>
<dt>In LTC</dt><dd class="mono"><%= fmt_ltc(ltc) %> LTC<% if @invoice.ltc_rate %> <span class="optional">@ <%= fmt_rate(@invoice.ltc_rate) %> <%= @invoice.currency %></span><% end %></dd>
<% end %>
<dt>Status</dt><dd><span class="badge badge-<%= @invoice.status %>"><%= @invoice.status %></span></dd> <dt>Status</dt><dd><span class="badge badge-<%= @invoice.status %>"><%= @invoice.status %></span></dd>
</dl> </dl>
</section> </section>