diff --git a/CLAUDE.md b/CLAUDE.md index 6894e93..54fc1bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: diff --git a/asxpio.rb b/asxpio.rb index 5cf9bc3..b07be5f 100644 --- a/asxpio.rb +++ b/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 diff --git a/db/migrations/009_invoice_cancel_hide.rb b/db/migrations/009_invoice_cancel_hide.rb new file mode 100644 index 0000000..2cb8018 --- /dev/null +++ b/db/migrations/009_invoice_cancel_hide.rb @@ -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 diff --git a/lib/invoice.rb b/lib/invoice.rb index 8f9761d..e69f6ed 100644 --- a/lib/invoice.rb +++ b/lib/invoice.rb @@ -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 diff --git a/lib/invoice_pdf.rb b/lib/invoice_pdf.rb index 0612d61..ee115bb 100644 --- a/lib/invoice_pdf.rb +++ b/lib/invoice_pdf.rb @@ -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' => { diff --git a/public/style.css b/public/style.css index 5bdd1d9..e3904ef 100644 --- a/public/style.css +++ b/public/style.css @@ -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); diff --git a/test/invoice_cancel_test.rb b/test/invoice_cancel_test.rb new file mode 100644 index 0000000..4b469b1 --- /dev/null +++ b/test/invoice_cancel_test.rb @@ -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{0unpaid}, last_response.body + end +end diff --git a/views/admin/invoices/index.erb b/views/admin/invoices/index.erb index b737f9c..7f174e9 100644 --- a/views/admin/invoices/index.erb +++ b/views/admin/invoices/index.erb @@ -24,7 +24,7 @@ <% @invoices.each do |inv| %> - <%= inv.number %> + <%= inv.number %><% if inv.hidden %> hidden<% end %> <%= inv.client_name %> <%= inv.issued_on.strftime('%Y-%m-%d') %> <%= inv.currency %> <%= '%.2f' % inv.total %> diff --git a/views/admin/invoices/show.erb b/views/admin/invoices/show.erb index 0718e42..70fdbbb 100644 --- a/views/admin/invoices/show.erb +++ b/views/admin/invoices/show.erb @@ -10,6 +10,12 @@
Due
<%= @invoice.due_on.strftime('%Y-%m-%d') %>
Total
<%= @invoice.currency %> <%= '%.2f' % @invoice.total %><% unless @invoice.currency == 'GEL' %> · GEL <%= '%.2f' % @invoice.total_gel %><% end %>
Status
<%= @invoice.status %>
+ <% if @invoice.cancelled? %> +
Cancelled
<%= @invoice.cancelled_at.utc.strftime('%Y-%m-%d %H:%M UTC') %><%= " · #{@invoice.cancel_reason}" if @invoice.cancel_reason %>
+ <% end %> + <% if @invoice.hidden %> +
Visibility
hidden only superusers see it in the admin
+ <% end %> <% if @invoice.paid_at %>
Paid at
<%= @invoice.paid_at.utc.strftime('%Y-%m-%d %H:%M UTC') %>
<% end %> @@ -35,10 +41,34 @@ https://asxp.io/i/<%= @invoice.uuid %>

<% if can?('invoices', 'write') %> -
- - +
+ <% end %> + <% if @invoice.cancelled? %> +
+ + +
+ <% else %> +
+ + + +
+ <% end %> + <% if current_user.superuser %> +
+ + +
+ <% end %> + + <% end %> <% end %> diff --git a/views/invoice_public.erb b/views/invoice_public.erb index 3e393ec..b437b5b 100644 --- a/views/invoice_public.erb +++ b/views/invoice_public.erb @@ -15,7 +15,7 @@ <% if @invoice.crypto? && (amt = @invoice.crypto_amount_due) %>
In <%= @invoice.crypto_coin %>
<%= fmt_crypto(amt) %> <%= @invoice.crypto_coin %><% if @invoice.crypto_rate %> @ <%= fmt_rate(@invoice.crypto_rate) %> <%= @invoice.currency %><% end %>
<% end %> -
Status
<%= @invoice.status %>
+
Status
<%= @invoice.status %><% if @invoice.cancelled? %> This invoice was cancelled and is not payable.<% end %>