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:
parent
0dc31fbafe
commit
77724e1ee2
1 changed files with 134 additions and 3 deletions
137
asxpio.rb
137
asxpio.rb
|
|
@ -1,4 +1,5 @@
|
||||||
require 'logger'
|
require 'logger'
|
||||||
|
require 'bigdecimal'
|
||||||
require 'sinatra/base'
|
require 'sinatra/base'
|
||||||
require 'sinatra/contrib'
|
require 'sinatra/contrib'
|
||||||
require 'erubi'
|
require 'erubi'
|
||||||
|
|
@ -10,13 +11,26 @@ begin
|
||||||
rescue LoadError
|
rescue LoadError
|
||||||
end
|
end
|
||||||
|
|
||||||
require_relative 'lib/mailer'
|
|
||||||
require_relative 'lib/rate_limit'
|
|
||||||
|
|
||||||
$root = __dir__
|
$root = __dir__
|
||||||
$logger = Logger.new($stdout)
|
$logger = Logger.new($stdout)
|
||||||
$env = ENV.fetch('RACK_ENV', 'development')
|
$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
|
class AsxpioWeb < Sinatra::Base
|
||||||
RATE_LIMIT = RateLimit.new(limit: 5, window: 3600)
|
RATE_LIMIT = RateLimit.new(limit: 5, window: 3600)
|
||||||
|
|
||||||
|
|
@ -35,6 +49,7 @@ class AsxpioWeb < Sinatra::Base
|
||||||
same_site: :lax
|
same_site: :lax
|
||||||
|
|
||||||
use Rack::Protection::AuthenticityToken
|
use Rack::Protection::AuthenticityToken
|
||||||
|
use AdminAuth
|
||||||
|
|
||||||
Mailer.configure!
|
Mailer.configure!
|
||||||
|
|
||||||
|
|
@ -116,7 +131,123 @@ class AsxpioWeb < Sinatra::Base
|
||||||
erb :thanks
|
erb :thanks
|
||||||
end
|
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
|
error 403 do
|
||||||
'Forbidden — likely CSRF token expired. Reload the page and try again.'
|
'Forbidden — likely CSRF token expired. Reload the page and try again.'
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue