nbg and auto fill GEL exchange rate
This commit is contained in:
parent
37a491cf79
commit
7dbeb0dd49
7 changed files with 161 additions and 5 deletions
39
asxpio.rb
39
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
|
||||
|
|
|
|||
61
lib/gel_rate.rb
Normal file
61
lib/gel_rate.rb
Normal 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
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -48,8 +48,10 @@
|
|||
</select>
|
||||
</div>
|
||||
<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 />
|
||||
<button type="button" id="fetch-gel-rate" class="link-button">↻ Fetch official (NBG)</button>
|
||||
<span id="gel-rate-status" class="optional"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
<% 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 %>
|
||||
<% 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>
|
||||
</dd>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@
|
|||
<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>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>
|
||||
</dl>
|
||||
</section>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue