invoices: let the admin set the payment date
All checks were successful
Build and Deploy to Production / test (push) Successful in 1m40s
Build and Deploy to Production / build (push) Successful in 1m19s
Build and Deploy to Production / deploy (push) Successful in 56s

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
This commit is contained in:
Sergei Poljanski 2026-11-03 01:47:03 +04:00
commit 9d88b8af8f
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
5 changed files with 68 additions and 4 deletions

View file

@ -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`.

View file

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

View file

@ -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);

View file

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

View file

@ -43,8 +43,19 @@
<% if can?('invoices', 'write') %>
<div class="invoice-actions">
<% unless @invoice.cancelled? %>
<form method="post" action="/admin/invoices/<%= @invoice.uuid %>/paid">
<% today = Time.now.utc.to_date.iso8601 %>
<% if @invoice.paid? %>
<form method="post" action="/admin/invoices/<%= @invoice.uuid %>/paid-date" class="invoice-form paid-form">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<input type="date" name="paid_on" required max="<%= today %>" value="<%= @invoice.paid_at.utc.to_date.iso8601 %>" aria-label="Payment date" />
<button type="submit" class="link-button">Change paid date</button>
</form>
<% end %>
<form method="post" action="/admin/invoices/<%= @invoice.uuid %>/paid" class="invoice-form paid-form">
<input type="hidden" name="authenticity_token" value="<%= csrf_token %>" />
<% unless @invoice.paid? %>
<input type="date" name="paid_on" max="<%= today %>" value="<%= today %>" aria-label="Payment date" />
<% end %>
<button type="submit" class="link-button"><%= @invoice.paid? ? 'Mark unpaid' : 'Mark paid' %></button>
</form>
<% end %>