invoicing: admin and public routes

Mounts AdminAuth (HTTP Basic on /admin/*) and adds two route groups:

- Admin (/admin/invoices): list, new form, create, show, toggle-paid.
  POST /admin/invoices builds the Invoice, renders the PDF, uploads to
  MinIO, then persists the row with the resulting pdf_key.

- Public (/i/:uuid): an HTML landing page showing client name, number,
  total, and status badge, plus /i/:uuid/pdf which 302s to a short-lived
  MinIO presigned URL. Anyone with the UUID can fetch the PDF — that's
  the whole point of the link-based delivery.

The Invoice model is required only when DATABASE_URL is set, so the
contact-form site still boots in dev without Postgres. Invoicing routes
return 503 in that mode rather than crashing on first request.
This commit is contained in:
Sergei Poljanski 2026-05-26 17:54:31 +03:00
commit 77724e1ee2
Signed by: asxpi
GPG key ID: 4F8851660FA4121B

137
asxpio.rb
View file

@ -1,4 +1,5 @@
require 'logger'
require 'bigdecimal'
require 'sinatra/base'
require 'sinatra/contrib'
require 'erubi'
@ -10,13 +11,26 @@ begin
rescue LoadError
end
require_relative 'lib/mailer'
require_relative 'lib/rate_limit'
$root = __dir__
$logger = Logger.new($stdout)
$env = ENV.fetch('RACK_ENV', 'development')
require_relative 'lib/mailer'
require_relative 'lib/rate_limit'
require_relative 'lib/db'
require_relative 'lib/admin_auth'
require_relative 'lib/s3'
# 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/invoice_pdf'
else
$logger.warn('DATABASE_URL not set — invoicing disabled')
end
class AsxpioWeb < Sinatra::Base
RATE_LIMIT = RateLimit.new(limit: 5, window: 3600)
@ -35,6 +49,7 @@ class AsxpioWeb < Sinatra::Base
same_site: :lax
use Rack::Protection::AuthenticityToken
use AdminAuth
Mailer.configure!
@ -116,7 +131,123 @@ class AsxpioWeb < Sinatra::Base
erb :thanks
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
get '/admin' do
redirect '/admin/invoices'
end
get '/admin/invoices' do
@page_title = 'Invoices — admin'
@noindex = true
@invoices = Invoice.order(Sequel.desc(:created_at)).all
erb :'admin/invoices/index'
end
get '/admin/invoices/new' do
@page_title = 'New invoice — admin'
@noindex = true
@form_errors = nil
@form_values = {}
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,
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
invoice = Invoice.build(@form_values)
pdf_bytes = InvoicePdf.render(invoice)
pdf_key = "invoices/#{invoice.number}-#{invoice.uuid}.pdf"
S3.put(pdf_key, pdf_bytes)
invoice.pdf_key = pdf_key
invoice.save_changes
redirect "/admin/invoices/#{invoice.uuid}"
end
get '/admin/invoices/:uuid' do
@invoice = Invoice[uuid: params[:uuid]] or halt 404
@page_title = "#{@invoice.number} — admin"
@noindex = true
erb :'admin/invoices/show'
end
post '/admin/invoices/:uuid/paid' do
invoice = Invoice[uuid: params[:uuid]] or halt 404
invoice.paid_at = invoice.paid? ? nil : Time.now.utc
invoice.save_changes
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
url = S3.presigned_url(invoice.pdf_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 (1200 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])
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?
v[:items] = items
errors
end
end
end