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
This commit is contained in:
parent
9a802106ce
commit
9d88b8af8f
5 changed files with 68 additions and 4 deletions
|
|
@ -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`.
|
- `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.
|
- `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.
|
- `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/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.
|
- `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`.
|
- `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`.
|
||||||
|
|
|
||||||
30
asxpio.rb
30
asxpio.rb
|
|
@ -104,6 +104,18 @@ class AsxpioWeb < Sinatra::Base
|
||||||
def admin_invoice!(uuid)
|
def admin_invoice!(uuid)
|
||||||
visible_invoices.first(uuid: uuid) or halt 404
|
visible_invoices.first(uuid: uuid) or halt 404
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
before do
|
before do
|
||||||
|
|
@ -368,12 +380,26 @@ class AsxpioWeb < Sinatra::Base
|
||||||
erb :'admin/invoices/show'
|
erb :'admin/invoices/show'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Toggles paid. An optional `paid_on` (YYYY-MM-DD) backdates the payment;
|
||||||
|
# blank or today means now.
|
||||||
post '/admin/invoices/:uuid/paid' do
|
post '/admin/invoices/:uuid/paid' do
|
||||||
invoice = admin_invoice!(params[:uuid])
|
invoice = admin_invoice!(params[:uuid])
|
||||||
halt 422, 'Cancelled invoices cannot be marked paid; reinstate it first.' if invoice.cancelled?
|
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
|
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}"
|
redirect "/admin/invoices/#{invoice.uuid}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -476,6 +476,7 @@ fieldset.plain { border: 0; margin: 0; padding: 0; min-width: 0; }
|
||||||
}
|
}
|
||||||
.invoice-actions .link-button { margin: 0; }
|
.invoice-actions .link-button { margin: 0; }
|
||||||
.cancel-form { display: flex; gap: 8px; flex: 1 1 320px; }
|
.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); }
|
.link-button.danger:hover { color: var(--danger); border-color: var(--danger-border); }
|
||||||
.badge-paid {
|
.badge-paid {
|
||||||
color: var(--paid);
|
color: var(--paid);
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,32 @@ class InvoiceCancelTest < Minitest::Test
|
||||||
assert_equal 'pending', @inv.status
|
assert_equal 'pending', @inv.status
|
||||||
end
|
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,
|
# Every state of the admin invoice page renders (pending, paid,
|
||||||
# cancelled, hidden), for a superuser and for a read-only user.
|
# cancelled, hidden), for a superuser and for a read-only user.
|
||||||
def test_admin_invoice_page_renders_in_every_state
|
def test_admin_invoice_page_renders_in_every_state
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,19 @@
|
||||||
<% if can?('invoices', 'write') %>
|
<% if can?('invoices', 'write') %>
|
||||||
<div class="invoice-actions">
|
<div class="invoice-actions">
|
||||||
<% unless @invoice.cancelled? %>
|
<% 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="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>
|
<button type="submit" class="link-button"><%= @invoice.paid? ? 'Mark unpaid' : 'Mark paid' %></button>
|
||||||
</form>
|
</form>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue