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:
parent
12d29dec0c
commit
9128867a33
7 changed files with 282 additions and 1 deletions
82
test/invoice_stats_test.rb
Normal file
82
test/invoice_stats_test.rb
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue