From 9d88b8af8f05227064ae6dc2b1a0478c331a8d80 Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Tue, 3 Nov 2026 01:47:03 +0400 Subject: [PATCH] invoices: let the admin set the payment date Payments often arrive before they are recorded, so stamping paid_at with the click time skews received-per-month and days-to-pay stats. Mark paid now takes an optional date, and a paid invoice gets a form to correct it. Past days are stored at 12:00 UTC so the date holds in any timezone; future and malformed dates are refused. The PDF does not print the date, so nothing is re-rendered. Assisted-by: Claude:opus-5-5 Claude-Session: https://claude.ai/code/session_01TCc5TvWnAs5xPvGMQXTJw3 --- CLAUDE.md | 2 +- asxpio.rb | 30 ++++++++++++++++++++++++++++-- public/style.css | 1 + test/invoice_cancel_test.rb | 26 ++++++++++++++++++++++++++ views/admin/invoices/show.erb | 13 ++++++++++++- 5 files changed, 68 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2140069..65b3d23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ Admin (HTTP Basic via `ADMIN_USER`/`ADMIN_PASSWORD`): - `GET /admin/invoices/new` — create form. Dynamic line items via `public/admin-invoice-form.js`. - `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/paid` — toggles `paid_at`; optional `paid_on` (YYYY-MM-DD, no future dates) backdates it to that day 12:00 UTC. `POST …/paid-date` corrects the date of a paid invoice. The PDF doesn't print the date, so neither re-renders. - `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. - `GET /admin/invoices/stats[?year=YYYY]` — `lib/invoice_stats.rb`: invoiced / received / outstanding in GEL at each invoice's captured rate (cancelled excluded), overdue, average days to pay, received per month by payment date (bar chart, `--chart-bar` validated with the dataviz skill against the dark surface) plus the same figures as a table, per-currency totals, top clients, crypto usage. Respects `visible_invoices`. diff --git a/asxpio.rb b/asxpio.rb index 8dfcf01..00ea5c1 100644 --- a/asxpio.rb +++ b/asxpio.rb @@ -104,6 +104,18 @@ class AsxpioWeb < Sinatra::Base def admin_invoice!(uuid) visible_invoices.first(uuid: uuid) or halt 404 end + + # Payment time from a YYYY-MM-DD form value: blank or today → now, + # an earlier day → that day 12:00 UTC (noon keeps it on the same date + # in any timezone the stats might be read in). Future dates are refused. + def paid_time(value) + value = value.to_s.strip + return Time.now.utc if value.empty? + date = Date.iso8601(value) rescue halt(422, 'Invalid payment date.') + today = Time.now.utc.to_date + halt 422, 'Payment date is in the future.' if date > today + date == today ? Time.now.utc : Time.utc(date.year, date.month, date.day, 12) + end end before do @@ -368,12 +380,26 @@ class AsxpioWeb < Sinatra::Base erb :'admin/invoices/show' end + # Toggles paid. An optional `paid_on` (YYYY-MM-DD) backdates the payment; + # blank or today means now. post '/admin/invoices/:uuid/paid' do 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.paid_at = invoice.paid? ? nil : paid_time(params[:paid_on]) invoice.save_changes - audit!(invoice.paid? ? 'invoice.paid' : 'invoice.unpaid', invoice.number) + audit!(invoice.paid? ? 'invoice.paid' : 'invoice.unpaid', invoice.number, + invoice.paid? ? invoice.paid_at.strftime('%Y-%m-%d') : nil) + redirect "/admin/invoices/#{invoice.uuid}" + end + + # Corrects the payment date of an already-paid invoice. + post '/admin/invoices/:uuid/paid-date' do + invoice = admin_invoice!(params[:uuid]) + halt 422, 'Invoice is not paid.' unless invoice.paid? + halt 422, 'Pick a payment date.' if params[:paid_on].to_s.strip.empty? + old = invoice.paid_at.utc.strftime('%Y-%m-%d') + invoice.update(paid_at: paid_time(params[:paid_on])) + audit!('invoice.paid_date', invoice.number, "#{old} → #{invoice.paid_at.strftime('%Y-%m-%d')}") redirect "/admin/invoices/#{invoice.uuid}" end diff --git a/public/style.css b/public/style.css index 4ccea3d..b7bf614 100644 --- a/public/style.css +++ b/public/style.css @@ -476,6 +476,7 @@ fieldset.plain { border: 0; margin: 0; padding: 0; min-width: 0; } } .invoice-actions .link-button { margin: 0; } .cancel-form { display: flex; gap: 8px; flex: 1 1 320px; } +.paid-form { display: flex; gap: 8px; align-items: center; } .link-button.danger:hover { color: var(--danger); border-color: var(--danger-border); } .badge-paid { color: var(--paid); diff --git a/test/invoice_cancel_test.rb b/test/invoice_cancel_test.rb index 913664f..817269c 100644 --- a/test/invoice_cancel_test.rb +++ b/test/invoice_cancel_test.rb @@ -44,6 +44,32 @@ class InvoiceCancelTest < Minitest::Test assert_equal 'pending', @inv.status end + def test_paid_date_can_be_backdated_and_changed + admin_login! + post "/admin/invoices/#{@inv.uuid}/paid", { authenticity_token: token, paid_on: '2026-01-15' } + assert_equal 302, last_response.status + assert_equal Time.utc(2026, 1, 15, 12), @inv.refresh.paid_at + + post "/admin/invoices/#{@inv.uuid}/paid-date", { authenticity_token: token, paid_on: '2026-02-01' } + assert_equal 302, last_response.status + assert_equal Date.new(2026, 2, 1), @inv.refresh.paid_at.utc.to_date + + future = (Date.today + 3).iso8601 + post "/admin/invoices/#{@inv.uuid}/paid-date", { authenticity_token: token, paid_on: future } + assert_equal 422, last_response.status + post "/admin/invoices/#{@inv.uuid}/paid-date", { authenticity_token: token, paid_on: 'nope' } + assert_equal 422, last_response.status + assert_equal Date.new(2026, 2, 1), @inv.refresh.paid_at.utc.to_date + + get "/admin/invoices/#{@inv.uuid}" + assert_includes last_response.body, 'value="2026-02-01"' + + post "/admin/invoices/#{@inv.uuid}/paid", { authenticity_token: token } + refute @inv.refresh.paid? + post "/admin/invoices/#{@inv.uuid}/paid", { authenticity_token: token, paid_on: '' } + assert_in_delta Time.now.utc, @inv.refresh.paid_at, 60 + end + # Every state of the admin invoice page renders (pending, paid, # cancelled, hidden), for a superuser and for a read-only user. def test_admin_invoice_page_renders_in_every_state diff --git a/views/admin/invoices/show.erb b/views/admin/invoices/show.erb index 04ce45b..175d7c4 100644 --- a/views/admin/invoices/show.erb +++ b/views/admin/invoices/show.erb @@ -43,8 +43,19 @@ <% if can?('invoices', 'write') %>
<% unless @invoice.cancelled? %> -
+ <% today = Time.now.utc.to_date.iso8601 %> + <% if @invoice.paid? %> + + + +
+ <% end %> + <% end %>