Crypto options were limited to a hard-coded asset list, with one address per coin in a side table. A payout address now carries everything an invoice needs: ticker, name, QR URI scheme, amount parameter, CoinGecko id, address and an active flag. Any coin can be added, several addresses per ticker are fine, and known tickers fill the QR and rate fields from the built-in templates. The new-invoice form picks a payout address. The invoice snapshots the ticker, address, name, scheme and amount parameter, and the PDF and QR render from that copy, so editing, retiring or deleting a payout entry never changes an issued invoice. "Fetch live" looks up the rate by the payout address's CoinGecko id. Migration 012 keeps the existing rows and backfills both the payout metadata and the snapshots on existing crypto invoices. Assisted-by: Claude:opus-5
486 lines
17 KiB
Ruby
486 lines
17 KiB
Ruby
require 'logger'
|
||
require 'bigdecimal'
|
||
require 'sinatra/base'
|
||
require 'sinatra/contrib'
|
||
require 'erubi'
|
||
require 'uri'
|
||
|
||
begin
|
||
require 'dotenv'
|
||
# Tests control ENV themselves; a developer's .env must not leak in (it may
|
||
# point DATABASE_URL at a real database).
|
||
Dotenv.load unless ENV['RACK_ENV'] == 'test'
|
||
rescue LoadError
|
||
end
|
||
|
||
$root = __dir__
|
||
$logger = Logger.new($stdout)
|
||
$env = ENV.fetch('RACK_ENV', 'development')
|
||
|
||
require_relative 'lib/fmt'
|
||
require_relative 'lib/mailer'
|
||
require_relative 'lib/rate_limit'
|
||
require_relative 'lib/db'
|
||
require_relative 'lib/admin_auth'
|
||
require_relative 'lib/s3'
|
||
require_relative 'lib/bingo_admin'
|
||
require_relative 'lib/users_admin'
|
||
|
||
# Invoice model needs a live Sequel connection at class-definition time.
|
||
if ENV['DATABASE_URL']
|
||
DB.connect!
|
||
DB.migrate!
|
||
require_relative 'lib/invoice'
|
||
require_relative 'lib/crypto_asset'
|
||
require_relative 'lib/crypto_rate'
|
||
require_relative 'lib/crypto_qr'
|
||
require_relative 'lib/gel_rate'
|
||
require_relative 'lib/invoice_pdf'
|
||
require_relative 'lib/invoice_stats'
|
||
require_relative 'lib/bingo'
|
||
Bingo.seed!
|
||
require_relative 'lib/user'
|
||
require_relative 'lib/audit'
|
||
require_relative 'lib/payout_address'
|
||
PayoutAddress.seed_from_env!
|
||
User.bootstrap!(ENV['ADMIN_USER'], ENV['ADMIN_PASSWORD'])
|
||
else
|
||
$logger.warn('DATABASE_URL not set — invoicing disabled')
|
||
end
|
||
|
||
class AsxpioWeb < Sinatra::Base
|
||
RATE_LIMIT = RateLimit.new(limit: 5, window: 3600)
|
||
|
||
configure do
|
||
set :root, $root
|
||
set :erb, layout: :layout, escape_html: true
|
||
set :show_exceptions, $env == 'development'
|
||
# Traefik only routes our hostnames anyway; this is defense in depth.
|
||
# 127.0.0.1 is the Docker HEALTHCHECK. Empty list (= check disabled)
|
||
# outside production so dev and rack-test hosts keep working.
|
||
set :host_authorization,
|
||
{ permitted_hosts: $env == 'production' ? ['asxp.io', 'www.asxp.io', '127.0.0.1'] : [] }
|
||
end
|
||
|
||
use Rack::Session::Cookie,
|
||
key: 'asxpio.session',
|
||
path: '/',
|
||
secret: ENV.fetch('SESSION_SECRET'),
|
||
expire_after: 3600,
|
||
same_site: :lax
|
||
|
||
use Rack::Protection::AuthenticityToken
|
||
register AdminAuth
|
||
register BingoAdmin
|
||
register UsersAdmin
|
||
|
||
Mailer.configure!
|
||
|
||
helpers do
|
||
def csrf_token
|
||
Rack::Protection::AuthenticityToken.token(session)
|
||
end
|
||
|
||
def client_ip
|
||
request.env['HTTP_X_FORWARDED_FOR']&.split(',')&.first&.strip ||
|
||
request.env['HTTP_X_REAL_IP'] ||
|
||
request.ip
|
||
end
|
||
|
||
def fmt_rate(value, min_dp = 4)
|
||
Fmt.rate(value, min_dp: min_dp)
|
||
end
|
||
|
||
def fmt_crypto(value)
|
||
Fmt.crypto(value)
|
||
end
|
||
|
||
# Hidden invoices exist only for superusers in the admin.
|
||
def visible_invoices
|
||
current_user&.superuser ? Invoice.dataset : Invoice.where(hidden: false)
|
||
end
|
||
|
||
def admin_invoice!(uuid)
|
||
visible_invoices.first(uuid: uuid) or halt 404
|
||
end
|
||
end
|
||
|
||
before do
|
||
cache_control :private, :must_revalidate, max_age: 0
|
||
end
|
||
|
||
get '/' do
|
||
erb :index
|
||
end
|
||
|
||
get '/keys' do
|
||
@page_title = 'Keys · Sergei Poljanski'
|
||
@page_desc = 'PGP fingerprint and SSH public key of Sergei Poljanski. Verify or download the key files.'
|
||
erb :keys
|
||
end
|
||
|
||
get '/contact' do
|
||
@page_title = 'Contact · Sergei Poljanski'
|
||
@page_desc = 'Contact Sergei Poljanski: email, Telegram, phone. Legal entity details for invoices and contracts.'
|
||
erb :contact
|
||
end
|
||
|
||
# Liveness for the Docker HEALTHCHECK and the deploy pipeline. Pings the DB
|
||
# when invoicing is configured — a wedged connection pool should read as
|
||
# unhealthy, not just "process exists".
|
||
get '/healthz' do
|
||
begin
|
||
DB.connection['SELECT 1'].get if defined?(Invoice)
|
||
rescue Sequel::Error
|
||
halt 503, 'db unreachable'
|
||
end
|
||
'ok'
|
||
end
|
||
|
||
# --- Admin: invoices ----------------------------------------------------
|
||
|
||
before '/admin/*' do
|
||
halt 503, 'Invoicing not configured (DATABASE_URL missing).' unless defined?(Invoice)
|
||
end
|
||
|
||
before '/i/*' do
|
||
halt 503, 'Invoicing not configured (DATABASE_URL missing).' unless defined?(Invoice)
|
||
end
|
||
|
||
# Overview of every admin area; each tile links into its section.
|
||
get '/admin' do
|
||
@page_title = 'Dashboard · admin'
|
||
@noindex = true
|
||
if can?('invoices', 'read')
|
||
unpaid = visible_invoices.where(paid_at: nil, cancelled_at: nil).all
|
||
@inv = {
|
||
count: visible_invoices.count,
|
||
unpaid: unpaid,
|
||
overdue: unpaid.count { |i| i.due_on < Date.today },
|
||
unpaid_totals: unpaid.group_by(&:currency).transform_values { |l| l.sum(&:total) },
|
||
recent: visible_invoices.order(Sequel.desc(:created_at)).limit(5).all
|
||
}
|
||
end
|
||
if can?('bingo', 'read')
|
||
board = Bingo::Board.load
|
||
@bingo = {
|
||
cells: board.cells.size,
|
||
hit: board.hit.count(true),
|
||
lines: board.wins.size,
|
||
reserve: [Bingo::Datacenter.count - board.cells.size, 0].max,
|
||
last_incident: board.incidents.first,
|
||
no_photo: board.cells.reject(&:photo_key)
|
||
}
|
||
end
|
||
erb :'admin/dashboard'
|
||
end
|
||
|
||
get '/admin/invoices' do
|
||
@page_title = 'Invoices · admin'
|
||
@noindex = true
|
||
@invoices = visible_invoices.order(Sequel.desc(:created_at)).all
|
||
erb :'admin/invoices/index'
|
||
end
|
||
|
||
# Live price for the new-invoice form's "Fetch live" button, using the
|
||
# chosen payout address's CoinGecko id. GEL is derived from the gel_rate
|
||
# the operator typed into the form.
|
||
get '/admin/crypto-rate' do
|
||
content_type :json
|
||
payout = PayoutAddress[params[:payout].to_i]
|
||
currency = params[:currency].to_s.upcase
|
||
gel_rate = params[:gel_rate].to_s.strip
|
||
halt 404, { error: 'Unknown payout address' }.to_json unless payout
|
||
begin
|
||
rate = CryptoRate.fetch(payout.coingecko, currency, gel_rate: gel_rate.empty? ? nil : gel_rate)
|
||
{ rate: rate.round(8).to_s('F'), coin: payout.coin, currency: currency }.to_json
|
||
rescue CryptoRate::Error => e
|
||
status 502
|
||
{ error: e.message }.to_json
|
||
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
|
||
|
||
# Defined before /admin/invoices/:uuid, which would otherwise match it.
|
||
get '/admin/invoices/stats' do
|
||
@page_title = 'Invoice statistics · admin'
|
||
@noindex = true
|
||
year = params[:year].to_s =~ /\A\d{4}\z/ ? params[:year].to_i : nil
|
||
@stats = InvoiceStats.new(visible_invoices.all, year: year)
|
||
erb :'admin/invoices/stats'
|
||
end
|
||
|
||
get '/admin/invoices/payout-addresses' do
|
||
@page_title = 'Payout addresses · admin'
|
||
@noindex = true
|
||
@rows = PayoutAddress.listing
|
||
@flash = session.delete(:payout_flash)
|
||
erb :'admin/invoices/payout_addresses'
|
||
end
|
||
|
||
# One form edits every row (payout[id][...]); payout[new][...] adds one,
|
||
# filling empty fields from the built-in template for a known ticker.
|
||
post '/admin/invoices/payout-addresses' do
|
||
errors, changed = [], []
|
||
(params['payout'] || {}).each do |id, raw|
|
||
fresh = id == 'new'
|
||
next if fresh && raw['address'].to_s.strip.empty?
|
||
v = PayoutAddress.clean(raw, fill_from_template: fresh)
|
||
if (e = PayoutAddress.error_for(v)) then errors << e; next end
|
||
stamp = { updated_by: current_user.username, updated_at: Time.now.utc }
|
||
if fresh
|
||
row = PayoutAddress.create(**v, active: true, **stamp)
|
||
changed << "+#{row.coin} #{row.name}"
|
||
else
|
||
row = PayoutAddress[id.to_i] or next
|
||
row.set(**v, active: raw['active'] == '1')
|
||
next unless row.modified?
|
||
row.set(**stamp).save_changes
|
||
changed << "#{row.coin} #{row.name}"
|
||
end
|
||
end
|
||
audit!('invoice.payout_addresses', nil, changed.join('; ')) if changed.any?
|
||
session[:payout_flash] = errors.any? ? errors.join('; ') : (changed.any? ? 'Saved.' : 'Nothing changed.')
|
||
redirect '/admin/invoices/payout-addresses'
|
||
end
|
||
|
||
# Existing invoices keep their own copy, so removal is safe.
|
||
post '/admin/invoices/payout-addresses/:id/delete' do
|
||
row = PayoutAddress[params[:id].to_i] or halt 404
|
||
row.destroy
|
||
audit!('invoice.payout_address_delete', row.coin, "#{row.name} #{row.address}")
|
||
session[:payout_flash] = "Removed #{row.coin} · #{row.name}."
|
||
redirect '/admin/invoices/payout-addresses'
|
||
end
|
||
|
||
get '/admin/invoices/new' do
|
||
@page_title = 'New invoice · admin'
|
||
@noindex = true
|
||
@form_errors = nil
|
||
@form_values = {}
|
||
erb :'admin/invoices/new'
|
||
end
|
||
|
||
# Start a new invoice prefilled from an existing one ("use as template").
|
||
# Reuses the new-invoice form via @form_values. Issue date resets to today and
|
||
# the due date keeps the source's payment term (same issued→due gap, anchored
|
||
# to today). The snapshotted LTC rate/amount are dropped (stale prices) — the
|
||
# address is kept so the operator only re-fetches the live rate.
|
||
get '/admin/invoices/:uuid/duplicate' do
|
||
src = admin_invoice!(params[:uuid])
|
||
term = (src.due_on - src.issued_on).to_i
|
||
@page_title = 'New invoice · admin'
|
||
@noindex = true
|
||
@form_errors = nil
|
||
@form_values = {
|
||
client_name: src.client_name,
|
||
client_email: src.client_email,
|
||
client_address: src.client_address,
|
||
currency: src.currency,
|
||
gel_rate: src.gel_rate.to_s,
|
||
issued_on: Date.today.strftime('%Y-%m-%d'),
|
||
due_on: (Date.today + term).strftime('%Y-%m-%d'),
|
||
notes: src.notes,
|
||
payout_id: PayoutAddress.first(coin: src.crypto_coin, address: src.crypto_address, active: true)&.id.to_s,
|
||
items: src.items_array
|
||
}
|
||
erb :'admin/invoices/new'
|
||
end
|
||
|
||
post '/admin/invoices' do
|
||
@form_values = {
|
||
client_name: params[:client_name].to_s.strip,
|
||
client_email: params[:client_email].to_s.strip,
|
||
client_address: params[:client_address].to_s.strip,
|
||
currency: params[:currency].to_s.upcase,
|
||
gel_rate: params[:gel_rate].to_s.strip,
|
||
issued_on: params[:issued_on].to_s.strip,
|
||
due_on: params[:due_on].to_s.strip,
|
||
notes: params[:notes].to_s.strip,
|
||
payout_id: params[:payout_id].to_s.strip,
|
||
crypto_rate: params[:crypto_rate].to_s.strip,
|
||
crypto_amount: params[:crypto_amount].to_s.strip,
|
||
items: (params[:items] || {}).values
|
||
}
|
||
@form_errors = validate_invoice_params(@form_values)
|
||
|
||
if @form_errors.any?
|
||
@page_title = 'New invoice · admin'
|
||
@noindex = true
|
||
status 422
|
||
return erb :'admin/invoices/new'
|
||
end
|
||
|
||
# The whole build→render→upload→save sequence retries on a duplicate
|
||
# number: two concurrent creates (Puma threads) can both compute MAX+1.
|
||
# The PDF embeds the number, so a retry must re-render, not just re-save.
|
||
# A losing attempt orphans its two S3 objects; harmless and near-impossible
|
||
# with a single operator.
|
||
build_params = @form_values.merge(@payout ? @payout.snapshot : {})
|
||
|
||
attempts = 0
|
||
begin
|
||
invoice = Invoice.build(build_params)
|
||
# Base key (no suffix); the two status variants get -pending/-paid appended.
|
||
invoice.pdf_key = "invoices/#{invoice.number}-#{invoice.uuid}.pdf"
|
||
%w[pending paid].each do |st|
|
||
S3.put(invoice.pdf_key_for(st), InvoicePdf.render(invoice, status: st))
|
||
end
|
||
invoice.save_changes
|
||
rescue Sequel::UniqueConstraintViolation
|
||
attempts += 1
|
||
raise if attempts > 2
|
||
retry
|
||
end
|
||
|
||
audit!('invoice.create', invoice.number, "#{invoice.client_name}, #{invoice.currency} #{'%.2f' % invoice.total}")
|
||
redirect "/admin/invoices/#{invoice.uuid}"
|
||
end
|
||
|
||
get '/admin/invoices/:uuid' do
|
||
@invoice = admin_invoice!(params[:uuid])
|
||
@page_title = "#{@invoice.number} · admin"
|
||
@noindex = true
|
||
erb :'admin/invoices/show'
|
||
end
|
||
|
||
post '/admin/invoices/:uuid/paid' do
|
||
invoice = admin_invoice!(params[:uuid])
|
||
halt 422, 'Cancelled invoices cannot be marked paid; reinstate it first.' if invoice.cancelled?
|
||
invoice.paid_at = invoice.paid? ? nil : Time.now.utc
|
||
invoice.save_changes
|
||
audit!(invoice.paid? ? 'invoice.paid' : 'invoice.unpaid', invoice.number)
|
||
redirect "/admin/invoices/#{invoice.uuid}"
|
||
end
|
||
|
||
# Cancel: a third PDF variant with a CANCELLED stamp becomes current, so
|
||
# the client's link and download switch over. Reinstate undoes it.
|
||
post '/admin/invoices/:uuid/cancel' do
|
||
invoice = admin_invoice!(params[:uuid])
|
||
halt 422, 'Already cancelled.' if invoice.cancelled?
|
||
invoice.cancelled_at = Time.now.utc
|
||
invoice.cancel_reason = params[:reason].to_s.strip[0, 500].then { |r| r.empty? ? nil : r }
|
||
S3.put(invoice.pdf_key_for('cancelled'), InvoicePdf.render(invoice, status: 'cancelled'))
|
||
invoice.save_changes
|
||
audit!('invoice.cancel', invoice.number, invoice.cancel_reason)
|
||
redirect "/admin/invoices/#{invoice.uuid}"
|
||
end
|
||
|
||
post '/admin/invoices/:uuid/reinstate' do
|
||
invoice = admin_invoice!(params[:uuid])
|
||
invoice.update(cancelled_at: nil, cancel_reason: nil)
|
||
audit!('invoice.reinstate', invoice.number)
|
||
redirect "/admin/invoices/#{invoice.uuid}"
|
||
end
|
||
|
||
# Superuser-only: hidden invoices vanish from the admin for everyone else
|
||
# (the client's public link keeps working).
|
||
post '/admin/invoices/:uuid/hide' do
|
||
halt 403, 'Only superusers can hide invoices.' unless current_user.superuser
|
||
invoice = admin_invoice!(params[:uuid])
|
||
invoice.update(hidden: !invoice.hidden)
|
||
audit!(invoice.hidden ? 'invoice.hide' : 'invoice.unhide', invoice.number)
|
||
redirect "/admin/invoices/#{invoice.uuid}"
|
||
end
|
||
|
||
# --- Public: invoice landing + PDF download -----------------------------
|
||
|
||
get '/i/:uuid' do
|
||
@invoice = Invoice[uuid: params[:uuid]] or halt 404
|
||
@page_title = "Invoice #{@invoice.number}"
|
||
@page_desc = "Invoice #{@invoice.number} from IE Sergei Poljanski."
|
||
@noindex = true
|
||
erb :invoice_public
|
||
end
|
||
|
||
get '/i/:uuid/pdf' do
|
||
invoice = Invoice[uuid: params[:uuid]] or halt 404
|
||
# Prefer the status variant; fall back to the legacy single-PDF key for
|
||
# invoices created before the pending/paid split.
|
||
key = invoice.current_pdf_key
|
||
key = invoice.pdf_key unless S3.exists?(key)
|
||
url = S3.presigned_url(key,
|
||
expires_in: 300,
|
||
filename: "#{invoice.number}.pdf")
|
||
redirect url, 302
|
||
end
|
||
|
||
not_found do
|
||
status 404
|
||
'Not found'
|
||
end
|
||
|
||
error 403 do
|
||
'Forbidden — likely CSRF token expired. Reload the page and try again.'
|
||
end
|
||
|
||
helpers do
|
||
def validate_invoice_params(v)
|
||
errors = {}
|
||
errors[:client_name] = 'Client name required (1–200 chars)' if v[:client_name].empty? || v[:client_name].length > 200
|
||
errors[:client_email] = 'Valid client email required' if v[:client_email].empty? || v[:client_email] !~ URI::MailTo::EMAIL_REGEXP
|
||
errors[:currency] = 'Unsupported currency' unless Invoice::CURRENCIES.include?(v[:currency])
|
||
begin
|
||
raise ArgumentError if v[:gel_rate].empty? || BigDecimal(v[:gel_rate]) <= 0
|
||
rescue ArgumentError
|
||
errors[:gel_rate] = 'GEL rate must be a positive decimal'
|
||
end
|
||
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?
|
||
# Blank qty/price default to 0 in Invoice.normalize_items; non-blank must
|
||
# parse, or BigDecimal would raise a 500 out of Invoice.build.
|
||
items.each do |i|
|
||
%w[qty unit_price].each do |k|
|
||
raw = i[k].to_s
|
||
next if raw.empty?
|
||
begin
|
||
BigDecimal(raw)
|
||
rescue ArgumentError
|
||
errors[:items] = 'Qty and unit price must be decimal numbers (use a dot, e.g. 1.5)'
|
||
end
|
||
end
|
||
end
|
||
v[:items] = items
|
||
|
||
# Crypto is optional: a chosen payout address (validated when it was
|
||
# saved) supplies ticker, address and QR metadata.
|
||
@payout = nil
|
||
unless v[:payout_id].to_s.empty?
|
||
@payout = PayoutAddress.first(id: v[:payout_id].to_i, active: true)
|
||
errors[:payout_id] = 'Pick an active payout address' unless @payout
|
||
%i[crypto_rate crypto_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
|
||
end
|