invoices: statistics page

Add /admin/invoices/stats with a year filter: invoiced, received and
outstanding totals in GEL at each invoice's captured rate (the one
currency every invoice has), overdue count, average days to pay, money
received per month by payment date as a bar chart with the same figures
in a table, per-currency totals, top clients and crypto option usage.
Cancelled invoices are left out of every amount; hidden ones follow the
usual superuser-only visibility.

Assisted-by: Claude:opus-5
This commit is contained in:
Sergei Poljanski 2026-10-10 09:12:21 +04:00
commit 9128867a33
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
7 changed files with 282 additions and 1 deletions

View file

@ -89,6 +89,7 @@ Admin (HTTP Basic via `ADMIN_USER`/`ADMIN_PASSWORD`):
- `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.
- `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`.
Public:

View file

@ -36,6 +36,7 @@ if ENV['DATABASE_URL']
require_relative 'lib/crypto_qr'
require_relative 'lib/gel_rate'
require_relative 'lib/invoice_pdf'
require_relative 'lib/invoice_stats'
require_relative 'lib/bingo'
Bingo.seed!
require_relative 'lib/user'
@ -223,6 +224,14 @@ class AsxpioWeb < Sinatra::Base
end
# Defined before /admin/invoices/:uuid, which would otherwise match it.
get '/admin/invoices/stats' do
@page_title = 'Invoice statistics · admin'
@noindex = true
year = params[:year].to_s =~ /\A\d{4}\z/ ? params[:year].to_i : nil
@stats = InvoiceStats.new(visible_invoices.all, year: year)
erb :'admin/invoices/stats'
end
get '/admin/invoices/payout-addresses' do
@page_title = 'Payout addresses · admin'
@noindex = true

67
lib/invoice_stats.rb Normal file
View file

@ -0,0 +1,67 @@
require 'bigdecimal'
# Figures for /admin/invoices/stats. Money is compared in GEL at each
# invoice's captured rate (total_gel), the one currency every invoice has.
# Cancelled invoices count only in `cancelled`.
class InvoiceStats
attr_reader :year, :invoices
def initialize(invoices, year: nil)
@all = invoices
@year = year
@invoices = year ? invoices.select { |i| i.issued_on.year == year } : invoices
end
def years
@all.map { |i| i.issued_on.year }.uniq.sort.reverse
end
def live = @live ||= invoices.reject(&:cancelled?)
def paid = live.select(&:paid?)
def unpaid = live.reject(&:paid?)
def overdue = unpaid.select { |i| i.due_on < Date.today }
def cancelled = invoices.select(&:cancelled?)
def gel(list) = list.sum(BigDecimal('0')) { |i| i.total_gel }
def avg_days_to_pay
days = paid.map { |i| (i.paid_at.to_date - i.issued_on).to_i }
days.empty? ? nil : (days.sum.to_f / days.size).round
end
# currency => { invoiced:, paid:, outstanding: } in the invoice currency
def by_currency
live.group_by(&:currency).sort.to_h.transform_values do |l|
{ invoiced: l.sum(BigDecimal('0'), &:total),
paid: l.select(&:paid?).sum(BigDecimal('0'), &:total),
outstanding: l.reject(&:paid?).sum(BigDecimal('0'), &:total) }
end
end
# [[Date (1st of month), issued count, invoiced GEL, received GEL]], oldest
# first. Received is by payment date, so it can fall in a later month.
def monthly
months =
if year then (1..12).map { |m| Date.new(year, m, 1) }
else
first = (live.map(&:issued_on) + paid.map { |i| i.paid_at.to_date }).min or return []
m = Date.new(first.year, first.month, 1)
last = Date.new(Date.today.year, Date.today.month, 1)
[].tap { |a| (a << m; m = m.next_month) while m <= last }
end
pool_paid = year ? @all.reject(&:cancelled?).select(&:paid?) : paid
months.map do |m|
issued = live.select { |i| i.issued_on.year == m.year && i.issued_on.month == m.month }
received = pool_paid.select { |i| d = i.paid_at.to_date; d.year == m.year && d.month == m.month }
[m, issued.size, gel(issued), gel(received)]
end
end
def top_clients(n = 5)
live.group_by(&:client_name).map { |c, l| [c, l.size, gel(l)] }.sort_by { |_, _, g| -g }.first(n)
end
def crypto_usage
live.select(&:crypto?).group_by(&:crypto_coin).map { |c, l| [c, l.size] }.sort_by { |_, n| -n }
end
end

View file

@ -74,6 +74,7 @@
--paid: #7AB97A;
--paid-border: #234A23;
--paid-bg: #142A14;
--chart-bar: #5B8FD9; /* single-series bars; validated vs --surface (dark) */
--font-sans: 'IBM Plex Sans', 'Noto Sans Georgian', sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
}
@ -380,6 +381,29 @@ fieldset.plain { border: 0; margin: 0; padding: 0; min-width: 0; }
.stats b { display: block; font-size: 1.4rem; color: var(--text-bright); font-variant-numeric: tabular-nums; }
.stats span { font-size: 0.75rem; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.06em; }
/* Invoice statistics */
.year-filter { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-bottom: 24px; }
.year-filter a { padding: 2px 10px; border: 1px solid var(--border); border-radius: 3px; text-decoration: none; color: var(--text-dim); font-family: var(--font-mono); font-size: 0.85rem; }
.year-filter a.active { color: var(--text-bright); border-color: var(--text-ghost); background: var(--surface); }
.stats-6 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.stats b small { font-size: 0.7rem; color: var(--text-faint); font-weight: 400; }
.bar-chart { display: grid; grid-template-columns: auto 1fr; gap: 8px; margin: 12px 0 0; height: 200px; }
.bar-axis { display: flex; flex-direction: column; justify-content: space-between; font-family: var(--font-mono); font-size: 0.7rem; line-height: 1; color: var(--text-faint); text-align: right; }
.bar-axis span:first-child { transform: translateY(-50%); } /* centre on the top gridline */
.bar-axis span:last-child { transform: translateY(50%); } /* centre on the baseline */
.bars { display: flex; align-items: stretch; gap: 2px; border-bottom: 1px solid var(--border); position: relative; }
.bar-col { flex: 1 1 0; position: relative; display: flex; flex-direction: column; justify-content: flex-end; outline: none; padding-bottom: 20px; margin-bottom: -20px; }
.bar { background: var(--chart-bar); border-radius: 4px 4px 0 0; min-height: 0; }
.bar-label { position: absolute; bottom: 2px; left: 0; right: 0; text-align: center; font-size: 0.65rem; color: var(--text-faint); overflow: hidden; white-space: nowrap; }
.bar-col:hover .bar, .bar-col:focus .bar { filter: brightness(1.2); }
.bar-col:hover::after, .bar-col:focus::after {
content: attr(data-tip); position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%);
background: var(--surface-alt); border: 1px solid var(--border-strong); color: var(--text-bright);
padding: 4px 8px; border-radius: 3px; font-size: 0.75rem; font-family: var(--font-mono); white-space: nowrap; z-index: 2; pointer-events: none;
}
.bar-col { height: 100%; }
@media (max-width: 560px) { .stats-6 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
/* Invoices */
.invoice-list {
width: 100%;

View file

@ -0,0 +1,82 @@
require_relative 'test_helper'
class InvoiceStatsTest < 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!
end
def make(client:, price:, issued:, paid: nil, cancelled: false, currency: 'EUR', rate: '3')
i = Invoice.build(client_name: client, client_email: 'c@example.com', currency: currency, gel_rate: rate,
issued_on: issued.to_s, due_on: (issued + 14).to_s,
items: [{ 'description' => 'work', 'qty' => '1', 'unit_price' => price.to_s }])
i.pdf_key = "invoices/#{i.number}-#{i.uuid}.pdf"
i.paid_at = Time.utc(paid.year, paid.month, paid.day, 12) if paid
i.cancelled_at = Time.now.utc if cancelled
i.save_changes
i
end
def test_figures
make(client: 'ACME', price: 100, issued: Date.new(2026, 1, 10), paid: Date.new(2026, 2, 5)) # 300 GEL, 26 days
make(client: 'ACME', price: 50, issued: Date.new(2026, 2, 1)) # 150 GEL, unpaid
make(client: 'Beta', price: 200, issued: Date.new(2026, 2, 3), cancelled: true) # ignored
make(client: 'Old', price: 10, issued: Date.new(2025, 12, 1), paid: Date.new(2026, 1, 2), currency: 'USD', rate: '2.7')
s = InvoiceStats.new(Invoice.all, year: 2026)
assert_equal 2, s.live.size
assert_equal 1, s.cancelled.size
assert_equal BigDecimal('450'), s.gel(s.live)
assert_equal BigDecimal('150'), s.gel(s.unpaid)
assert_equal 26, s.avg_days_to_pay
jan = s.monthly[0]
feb = s.monthly[1]
assert_equal [1, BigDecimal('300')], [jan[1], jan[2]]
assert_equal BigDecimal('27'), jan[3] # 2025 invoice paid in Jan 2026 counts as received then
assert_equal BigDecimal('300'), feb[3] # paid by payment date
assert_equal 12, s.monthly.size
assert_equal [['ACME', 2, BigDecimal('450')]], s.top_clients
assert_equal [2026, 2025], s.years
all = InvoiceStats.new(Invoice.all)
assert_equal 3, all.live.size
assert_equal({ 'EUR' => { invoiced: BigDecimal('150'), paid: BigDecimal('100'), outstanding: BigDecimal('50') },
'USD' => { invoiced: BigDecimal('10'), paid: BigDecimal('10'), outstanding: BigDecimal('0') } }, all.by_currency)
end
def test_page_renders_and_respects_hidden
make(client: 'ACME', price: 100, issued: Date.today - 20, paid: Date.today - 5)
h = make(client: 'Secret Ltd', price: 999, issued: Date.today - 3)
h.update(hidden: true)
admin_login!
get '/admin/invoices/stats'
assert last_response.ok?, last_response.body[0, 300]
assert_includes last_response.body, 'Secret Ltd' # superuser sees hidden
assert_includes last_response.body, 'class="bar-chart"'
get "/admin/invoices/stats?year=#{Date.today.year}"
assert last_response.ok?
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)
admin_login!(username: 'clerk', password: 'clerk-password-1')
get '/admin/invoices/stats'
assert last_response.ok?
refute_includes last_response.body, 'Secret Ltd'
end
def test_empty_state
admin_login!
get '/admin/invoices/stats'
assert last_response.ok?
end
end

View file

@ -1,7 +1,7 @@
<%== erb :'admin/_nav', layout: false %>
<div class="header">
<h1>Invoices</h1>
<p class="tagline"><% if can?('invoices', 'write') %><a href="/admin/invoices/new">+ New invoice</a> · <% end %><a href="/admin/invoices/payout-addresses">payout addresses</a></p>
<p class="tagline"><% if can?('invoices', 'write') %><a href="/admin/invoices/new">+ New invoice</a> · <% end %><a href="/admin/invoices/stats">statistics</a> · <a href="/admin/invoices/payout-addresses">payout addresses</a></p>
</div>
<% if @invoices.empty? %>

View file

@ -0,0 +1,98 @@
<% s = @stats %>
<% money = ->(v) { whole, frac = ('%.2f' % v).split('.'); "#{whole.reverse.scan(/\d{1,3}/).join(',').reverse.sub(/\A,/, '')}.#{frac}" } %>
<% gel = ->(v) { "₾ #{money.(v)}" } %>
<%== erb :'admin/_nav', layout: false %>
<div class="header">
<h1>Invoice statistics</h1>
<p class="tagline"><a href="/admin/invoices">← invoices</a></p>
</div>
<nav class="year-filter" aria-label="Period">
<a href="/admin/invoices/stats" class="<%= 'active' unless s.year %>">all time</a>
<% s.years.each do |y| %><a href="/admin/invoices/stats?year=<%= y %>" class="<%= 'active' if s.year == y %>"><%= y %></a><% end %>
</nav>
<section class="section">
<div class="stats stats-6">
<div><b><%= gel.(s.gel(s.live)) %></b><span>invoiced</span></div>
<div><b><%= gel.(s.gel(s.paid)) %></b><span>received</span></div>
<div><b><%= gel.(s.gel(s.unpaid)) %></b><span>outstanding</span></div>
<div><b><%= s.overdue.size %></b><span>overdue</span></div>
<div><b><%= s.avg_days_to_pay ? "#{s.avg_days_to_pay} d" : '—' %></b><span>avg. time to pay</span></div>
<div><b><%= s.live.size %><% if s.cancelled.any? %> <small>+<%= s.cancelled.size %> cancelled</small><% end %></b><span>invoices</span></div>
</div>
<p class="optional">GEL at each invoice's captured rate. Cancelled invoices are excluded from all amounts.</p>
</section>
<% rows = s.monthly %>
<% if rows.any? %>
<section class="section">
<h2>Received per month <span class="optional">(₾, by payment date)</span></h2>
<% peak = rows.map(&:last).max %>
<% peak = BigDecimal('1') if peak.nil? || peak.zero? %>
<figure class="bar-chart" role="img" aria-label="Received per month in GEL; exact values in the table below">
<div class="bar-axis"><span><%= gel.(peak) %></span><span>0</span></div>
<div class="bars">
<% rows.each do |m, _n, _inv, rec| %>
<div class="bar-col" tabindex="0" data-tip="<%= m.strftime('%b %Y') %>: <%= gel.(rec) %>">
<div class="bar" style="height: <%= (rec / peak * 100).round(1).to_f %>%"></div>
<span class="bar-label"><%= rows.size > 12 ? m.strftime('%m/%y') : m.strftime('%b') %></span>
</div>
<% end %>
</div>
</figure>
</section>
<section class="section">
<h2>By month</h2>
<table class="invoice-list">
<thead><tr><th>Month</th><th class="num">Issued</th><th class="num">Invoiced ₾</th><th class="num">Received ₾</th></tr></thead>
<tbody>
<% rows.reverse_each do |m, n, inv, rec| %>
<% next if n.zero? && rec.zero? %>
<tr><td class="mono"><%= m.strftime('%Y-%m') %></td><td class="num mono"><%= n %></td><td class="num mono"><%= money.(inv) %></td><td class="num mono"><%= money.(rec) %></td></tr>
<% end %>
</tbody>
</table>
</section>
<% end %>
<div class="dash">
<section class="section">
<h2>By currency</h2>
<table class="invoice-list">
<thead><tr><th>Currency</th><th class="num">Invoiced</th><th class="num">Received</th><th class="num">Outstanding</th></tr></thead>
<tbody>
<% s.by_currency.each do |cur, v| %>
<tr><td class="mono"><%= cur %></td><td class="num mono"><%= money.(v[:invoiced]) %></td><td class="num mono"><%= money.(v[:paid]) %></td><td class="num mono"><%= money.(v[:outstanding]) %></td></tr>
<% end %>
</tbody>
</table>
</section>
<section class="section">
<h2>Top clients</h2>
<table class="invoice-list">
<thead><tr><th>Client</th><th class="num">Invoices</th><th class="num">Invoiced ₾</th></tr></thead>
<tbody>
<% s.top_clients.each do |client, n, g| %>
<tr><td><%= client %></td><td class="num mono"><%= n %></td><td class="num mono"><%= money.(g) %></td></tr>
<% end %>
</tbody>
</table>
</section>
<% if s.crypto_usage.any? %>
<section class="section">
<h2>Crypto payment option</h2>
<table class="invoice-list">
<thead><tr><th>Asset</th><th class="num">Invoices</th></tr></thead>
<tbody>
<% s.crypto_usage.each do |coin, n| %>
<tr><td><span class="mono"><%= coin %></span> <span class="optional"><%= CryptoAsset.name(coin) %></span></td><td class="num mono"><%= n %></td></tr>
<% end %>
</tbody>
</table>
</section>
<% end %>
</div>