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:
parent
97919f3f71
commit
eeac07ed34
10 changed files with 231 additions and 13 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
54
asxpio.rb
54
asxpio.rb
|
|
@ -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
|
||||
|
|
|
|||
9
db/migrations/009_invoice_cancel_hide.rb
Normal file
9
db/migrations/009_invoice_cancel_hide.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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' => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
84
test/invoice_cancel_test.rb
Normal file
84
test/invoice_cancel_test.rb
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue