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
82 lines
3.3 KiB
Ruby
82 lines
3.3 KiB
Ruby
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
|