invoices: cancel with stamped PDF, superuser-only hiding

An invoice issued by mistake could only be left pending or marked paid.
Add cancel (with an optional reason) and reinstate. Cancelling renders a
third PDF variant with a diagonal CANCELLED stamp and the date/reason,
and it becomes the current one, so the client's link and download show
the invoice as void. Cancelled wins over paid in the status, and marking
a cancelled invoice paid is refused.

Superusers can also hide an invoice: it disappears from the admin list,
dashboard and invoice pages for every other user, while the client's
public link keeps working.

Assisted-by: Claude:opus-5
This commit is contained in:
Sergei Poljanski 2026-10-10 08:35:01 +04:00
commit eeac07ed34
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
10 changed files with 231 additions and 13 deletions

View file

@ -91,6 +91,15 @@ class AsxpioWeb < Sinatra::Base
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
@ -140,13 +149,13 @@ class AsxpioWeb < Sinatra::Base
@page_title = 'Dashboard · admin'
@noindex = true
if can?('invoices', 'read')
unpaid = Invoice.where(paid_at: nil).all
unpaid = visible_invoices.where(paid_at: nil, cancelled_at: nil).all
@inv = {
count: Invoice.count,
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: Invoice.order(Sequel.desc(:created_at)).limit(5).all
recent: visible_invoices.order(Sequel.desc(:created_at)).limit(5).all
}
end
if can?('bingo', 'read')
@ -166,7 +175,7 @@ class AsxpioWeb < Sinatra::Base
get '/admin/invoices' do
@page_title = 'Invoices · admin'
@noindex = true
@invoices = Invoice.order(Sequel.desc(:created_at)).all
@invoices = visible_invoices.order(Sequel.desc(:created_at)).all
erb :'admin/invoices/index'
end
@ -225,7 +234,7 @@ class AsxpioWeb < Sinatra::Base
# 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 = Invoice[uuid: params[:uuid]] or halt 404
src = admin_invoice!(params[:uuid])
term = (src.due_on - src.issued_on).to_i
@page_title = 'New invoice · admin'
@noindex = true
@ -296,20 +305,51 @@ class AsxpioWeb < Sinatra::Base
end
get '/admin/invoices/:uuid' do
@invoice = Invoice[uuid: params[:uuid]] or halt 404
@invoice = admin_invoice!(params[:uuid])
@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 = 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