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

@ -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 %>