From 77724e1ee23c5a476a070b9ba4c2350148af680f Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Tue, 26 May 2026 17:54:31 +0300 Subject: [PATCH] invoicing: admin and public routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- asxpio.rb | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 134 insertions(+), 3 deletions(-) diff --git a/asxpio.rb b/asxpio.rb index c131fc3..a611490 100644 --- a/asxpio.rb +++ b/asxpio.rb @@ -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 (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]) + 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