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

@ -87,6 +87,8 @@ Admin (HTTP Basic via `ADMIN_USER`/`ADMIN_PASSWORD`):
- `POST /admin/invoices` — validate → allocate number → render PDF → upload to MinIO → persist row.
- `GET /admin/invoices/:uuid` — detail page with public URL + "Mark paid" toggle.
- `POST /admin/invoices/:uuid/paid` — toggles `paid_at`.
- `POST /admin/invoices/:uuid/cancel` (optional `reason`) — sets `cancelled_at`/`cancel_reason`, renders and uploads a third PDF variant (`…-cancelled.pdf`, diagonal CANCELLED stamp + date/reason line) which becomes current, so the client link and download switch over. Status precedence: cancelled > paid > pending; marking paid is refused while cancelled. `POST …/reinstate` clears it.
- `POST /admin/invoices/:uuid/hide` — superuser-only toggle of `hidden`: hidden invoices are left out of the admin list, dashboard and admin invoice pages for everyone but superusers (`visible_invoices` / `admin_invoice!` helpers). The public `/i/:uuid` link is unaffected.
Public:

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

View file

@ -0,0 +1,9 @@
Sequel.migration do
change do
alter_table(:invoices) do
add_column :cancelled_at, DateTime # NULL = not cancelled
add_column :cancel_reason, String, text: true
add_column :hidden, TrueClass, null: false, default: false # admin: superusers only
end
end
end

View file

@ -24,7 +24,13 @@ class Invoice < Sequel::Model(:invoices)
!paid_at.nil?
end
def cancelled?
!cancelled_at.nil?
end
# Cancellation wins over payment: a cancelled invoice is no longer owed.
def status
return 'cancelled' if cancelled?
paid? ? 'paid' : 'pending'
end

View file

@ -31,6 +31,7 @@ class InvoicePdf
COLOR_MUTED = '6F6F6F'.freeze
COLOR_RULE = 'DADADA'.freeze
COLOR_ACCENT = '1A1A1A'.freeze
COLOR_VOID = 'C0392B'.freeze
# Uniform line spacing for the FROM / BILL TO party blocks: every line gets the
# same leading, no extra gaps between groups, so both columns read as one even
@ -71,12 +72,38 @@ class InvoicePdf
draw_totals(pdf)
draw_payment(pdf)
draw_footer(pdf)
draw_cancelled_stamp(pdf) if @status == 'cancelled'
pdf.render
end
private
# Diagonal stamp on every page plus the date/reason under it, so a
# cancelled invoice can't be mistaken for a payable one.
def draw_cancelled_stamp(pdf)
when_text = (@invoice.cancelled_at || Time.now.utc).strftime('%Y-%m-%d')
reason = @invoice.cancel_reason.to_s.strip
pdf.repeat(:all) do
pdf.fill_color COLOR_VOID
pdf.transparent(0.18) do
pdf.rotate(35, origin: [pdf.bounds.width / 2, pdf.bounds.height / 2]) do
pdf.font_size(96) do
pdf.draw_text 'CANCELLED', style: :bold,
at: [pdf.bounds.width / 2 - pdf.width_of('CANCELLED', style: :bold) / 2, pdf.bounds.height / 2 - 30]
end
end
end
pdf.transparent(0.9) do
pdf.font_size(9) do
line = "Cancelled #{when_text}#{reason.empty? ? '' : ": #{reason}"}"
pdf.draw_text line, at: [0, pdf.bounds.height - 4]
end
end
pdf.fill_color COLOR_TEXT
end
end
def register_fonts(pdf)
pdf.font_families.update(
'NotoSans' => {

View file

@ -420,6 +420,26 @@ fieldset.plain { border: 0; margin: 0; padding: 0; min-width: 0; }
border-color: var(--pending-border);
background: var(--pending-bg);
}
.badge-cancelled {
color: var(--danger);
border-color: var(--danger-border);
background: var(--danger-bg);
}
.badge-hidden {
color: var(--text-dim);
border-color: var(--border-strong);
background: var(--surface);
}
.invoice-actions {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
margin-top: 14px;
}
.invoice-actions .link-button { margin: 0; }
.cancel-form { display: flex; gap: 8px; flex: 1 1 320px; }
.link-button.danger:hover { color: var(--danger); border-color: var(--danger-border); }
.badge-paid {
color: var(--paid);
border-color: var(--paid-border);

View file

@ -0,0 +1,84 @@
require_relative 'test_helper'
class InvoiceCancelTest < Minitest::Test
include Rack::Test::Methods
include AdminLogin
def app
AsxpioWeb
end
def setup
skip 'TEST_DATABASE_URL not set' unless TestDb.available?
TestDb.clean!
TestDb.clean_users!
@inv = Invoice.build(client_name: 'ACME', client_email: 'b@example.com', currency: 'EUR', gel_rate: '3.05',
items: [{ 'description' => 'work', 'qty' => '1', 'unit_price' => '100' }])
@inv.pdf_key = "invoices/#{@inv.number}-#{@inv.uuid}.pdf"
@inv.save_changes
end
def token
get '/admin'
last_response.body[/name="authenticity_token" value="([^"]+)"/, 1]
end
def test_cancel_marks_status_and_renders_cancelled_pdf
admin_login!
post "/admin/invoices/#{@inv.uuid}/cancel", { authenticity_token: token, reason: 'Duplicate of INV-1' }
assert_equal 302, last_response.status
@inv.refresh
assert @inv.cancelled?
assert_equal 'cancelled', @inv.status
assert_equal 'Duplicate of INV-1', @inv.cancel_reason
assert_match(/-cancelled\.pdf\z/, @inv.current_pdf_key)
get "/i/#{@inv.uuid}"
assert_includes last_response.body, 'badge-cancelled'
assert_includes last_response.body, 'not payable'
post "/admin/invoices/#{@inv.uuid}/paid", { authenticity_token: token }
assert_equal 422, last_response.status
post "/admin/invoices/#{@inv.uuid}/reinstate", { authenticity_token: token }
refute @inv.refresh.cancelled?
assert_equal 'pending', @inv.status
end
def test_cancelled_pdf_renders
@inv.cancelled_at = Time.now.utc
@inv.cancel_reason = 'wrong client'
pdf = InvoicePdf.render(@inv, status: 'cancelled')
assert pdf.start_with?('%PDF')
end
def test_hidden_invoice_only_for_superusers
admin_login!
post "/admin/invoices/#{@inv.uuid}/hide", { authenticity_token: token }
assert @inv.refresh.hidden
get '/admin/invoices'
assert_includes last_response.body, 'badge-hidden'
clear_cookies
@admin_logged_in = false
u = User.create(username: 'clerk', password: 'clerk-password-1')
UserPermission.create(user_id: u.id, area: 'invoices', can_read: true, can_write: true)
admin_login!(username: 'clerk', password: 'clerk-password-1')
get '/admin/invoices'
refute_includes last_response.body, @inv.number
get "/admin/invoices/#{@inv.uuid}"
assert_equal 404, last_response.status
post "/admin/invoices/#{@inv.uuid}/hide", { authenticity_token: token }
assert_equal 403, last_response.status
get '/admin'
refute_includes last_response.body, @inv.number
get "/i/#{@inv.uuid}"
assert last_response.ok? # client link unaffected
end
def test_dashboard_ignores_cancelled_for_outstanding
@inv.update(cancelled_at: Time.now.utc)
admin_login!
get '/admin'
assert_match %r{<b>0</b><span>unpaid</span>}, last_response.body
end
end

View file

@ -24,7 +24,7 @@
<tbody>
<% @invoices.each do |inv| %>
<tr>
<td class="mono"><%= inv.number %></td>
<td class="mono"><%= inv.number %><% if inv.hidden %> <span class="badge badge-hidden">hidden</span><% end %></td>
<td><%= inv.client_name %></td>
<td class="mono"><%= inv.issued_on.strftime('%Y-%m-%d') %></td>
<td class="num mono"><%= inv.currency %> <%= '%.2f' % inv.total %></td>

View file

@ -10,6 +10,12 @@
<dt>Due</dt><dd class="mono"><%= @invoice.due_on.strftime('%Y-%m-%d') %></dd>
<dt>Total</dt><dd class="mono"><%= @invoice.currency %> <%= '%.2f' % @invoice.total %><% unless @invoice.currency == 'GEL' %> · GEL <%= '%.2f' % @invoice.total_gel %><% end %></dd>
<dt>Status</dt><dd><span class="badge badge-<%= @invoice.status %>"><%= @invoice.status %></span></dd>
<% if @invoice.cancelled? %>
<dt>Cancelled</dt><dd class="mono"><%= @invoice.cancelled_at.utc.strftime('%Y-%m-%d %H:%M UTC') %><%= " · #{@invoice.cancel_reason}" if @invoice.cancel_reason %></dd>
<% end %>
<% if @invoice.hidden %>
<dt>Visibility</dt><dd><span class="badge badge-hidden">hidden</span> <span class="optional">only superusers see it in the admin</span></dd>
<% end %>
<% if @invoice.paid_at %>
<dt>Paid at</dt><dd class="mono"><%= @invoice.paid_at.utc.strftime('%Y-%m-%d %H:%M UTC') %></dd>
<% end %>
@ -35,10 +41,34 @@
<code class="mono">https://asxp.io/i/<%= @invoice.uuid %></code>
</p>
<% if can?('invoices', 'write') %>
<form method="post" action="/admin/invoices/<%= @invoice.uuid %>/paid" style="margin-top: 14px;">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit">
<% if @invoice.paid? %>Mark unpaid<% else %>Mark paid<% end %>
<div class="invoice-actions">
<% unless @invoice.cancelled? %>
<form method="post" action="/admin/invoices/<%= @invoice.uuid %>/paid">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button"><%= @invoice.paid? ? 'Mark unpaid' : 'Mark paid' %></button>
</form>
<% end %>
<% if @invoice.cancelled? %>
<form method="post" action="/admin/invoices/<%= @invoice.uuid %>/reinstate">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button">↺ Reinstate</button>
</form>
<% else %>
<form method="post" action="/admin/invoices/<%= @invoice.uuid %>/cancel" class="invoice-form cancel-form"
onsubmit="return confirm('Cancel this invoice? The client link and PDF will show it as CANCELLED.')">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<input type="text" name="reason" maxlength="500" placeholder="Reason (optional, printed on the PDF)" />
<button type="submit" class="link-button danger">Cancel invoice</button>
</form>
<% end %>
<% if current_user.superuser %>
<form method="post" action="/admin/invoices/<%= @invoice.uuid %>/hide">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<button type="submit" class="link-button"><%= @invoice.hidden ? 'Unhide' : 'Hide (superusers only)' %></button>
</form>
<% end %>
</div>
<% end %>
</button>
</form>
<% end %>

View file

@ -15,7 +15,7 @@
<% if @invoice.crypto? && (amt = @invoice.crypto_amount_due) %>
<dt>In <%= @invoice.crypto_coin %></dt><dd class="mono"><%= fmt_crypto(amt) %> <%= @invoice.crypto_coin %><% if @invoice.crypto_rate %> <span class="optional">@ <%= fmt_rate(@invoice.crypto_rate) %> <%= @invoice.currency %></span><% end %></dd>
<% end %>
<dt>Status</dt><dd><span class="badge badge-<%= @invoice.status %>"><%= @invoice.status %></span></dd>
<dt>Status</dt><dd><span class="badge badge-<%= @invoice.status %>"><%= @invoice.status %></span><% if @invoice.cancelled? %> <span class="optional">This invoice was cancelled and is not payable.</span><% end %></dd>
</dl>
</section>