From 9128867a3335aeea4eeee804bc8e4564d00055ac Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Sat, 10 Oct 2026 09:12:21 +0400 Subject: [PATCH] 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 --- CLAUDE.md | 1 + asxpio.rb | 9 ++++ lib/invoice_stats.rb | 67 +++++++++++++++++++++++ public/style.css | 24 +++++++++ test/invoice_stats_test.rb | 82 ++++++++++++++++++++++++++++ views/admin/invoices/index.erb | 2 +- views/admin/invoices/stats.erb | 98 ++++++++++++++++++++++++++++++++++ 7 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 lib/invoice_stats.rb create mode 100644 test/invoice_stats_test.rb create mode 100644 views/admin/invoices/stats.erb diff --git a/CLAUDE.md b/CLAUDE.md index 5cdef88..68758d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: diff --git a/asxpio.rb b/asxpio.rb index 447b15e..12e2dcf 100644 --- a/asxpio.rb +++ b/asxpio.rb @@ -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 diff --git a/lib/invoice_stats.rb b/lib/invoice_stats.rb new file mode 100644 index 0000000..d3e487c --- /dev/null +++ b/lib/invoice_stats.rb @@ -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 diff --git a/public/style.css b/public/style.css index e3904ef..baf8152 100644 --- a/public/style.css +++ b/public/style.css @@ -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%; diff --git a/test/invoice_stats_test.rb b/test/invoice_stats_test.rb new file mode 100644 index 0000000..e70aa14 --- /dev/null +++ b/test/invoice_stats_test.rb @@ -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 diff --git a/views/admin/invoices/index.erb b/views/admin/invoices/index.erb index ec3c7ce..c544c5c 100644 --- a/views/admin/invoices/index.erb +++ b/views/admin/invoices/index.erb @@ -1,7 +1,7 @@ <%== erb :'admin/_nav', layout: false %>

Invoices

-

<% if can?('invoices', 'write') %>+ New invoice · <% end %>payout addresses

+

<% if can?('invoices', 'write') %>+ New invoice · <% end %>statistics · payout addresses

<% if @invoices.empty? %> diff --git a/views/admin/invoices/stats.erb b/views/admin/invoices/stats.erb new file mode 100644 index 0000000..11de5c9 --- /dev/null +++ b/views/admin/invoices/stats.erb @@ -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 %> +
+

Invoice statistics

+

← invoices

+
+ + + +
+
+
<%= gel.(s.gel(s.live)) %>invoiced
+
<%= gel.(s.gel(s.paid)) %>received
+
<%= gel.(s.gel(s.unpaid)) %>outstanding
+
<%= s.overdue.size %>overdue
+
<%= s.avg_days_to_pay ? "#{s.avg_days_to_pay} d" : '—' %>avg. time to pay
+
<%= s.live.size %><% if s.cancelled.any? %> +<%= s.cancelled.size %> cancelled<% end %>invoices
+
+

GEL at each invoice's captured rate. Cancelled invoices are excluded from all amounts.

+
+ +<% rows = s.monthly %> +<% if rows.any? %> +
+

Received per month (₾, by payment date)

+ <% peak = rows.map(&:last).max %> + <% peak = BigDecimal('1') if peak.nil? || peak.zero? %> + +
+ +
+

By month

+ + + + <% rows.reverse_each do |m, n, inv, rec| %> + <% next if n.zero? && rec.zero? %> + + <% end %> + +
MonthIssuedInvoiced ₾Received ₾
<%= m.strftime('%Y-%m') %><%= n %><%= money.(inv) %><%= money.(rec) %>
+
+<% end %> + +
+
+

By currency

+ + + + <% s.by_currency.each do |cur, v| %> + + <% end %> + +
CurrencyInvoicedReceivedOutstanding
<%= cur %><%= money.(v[:invoiced]) %><%= money.(v[:paid]) %><%= money.(v[:outstanding]) %>
+
+ +
+

Top clients

+ + + + <% s.top_clients.each do |client, n, g| %> + + <% end %> + +
ClientInvoicesInvoiced ₾
<%= client %><%= n %><%= money.(g) %>
+
+ + <% if s.crypto_usage.any? %> +
+

Crypto payment option

+ + + + <% s.crypto_usage.each do |coin, n| %> + + <% end %> + +
AssetInvoices
<%= coin %> <%= CryptoAsset.name(coin) %><%= n %>
+
+ <% end %> +