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 %> +