diff --git a/app/admin/leaderboard.rb b/app/admin/leaderboard.rb
new file mode 100644
index 00000000..124954a2
--- /dev/null
+++ b/app/admin/leaderboard.rb
@@ -0,0 +1,48 @@
+ActiveAdmin.register_page "Leaderboard" do
+ # Nested under the Money tab, and hidden from the nav for anyone who isn't
+ # an admin. Menu visibility is presentation, not access control —
+ # require_admin! below is what actually blocks a hand-typed URL.
+ menu label: "Leaderboard",
+ parent: "Money",
+ priority: 20,
+ if: proc { current_admin_user&.is_admin? }
+
+ # The global AuthorizationAdapter lets project leads through for most pages,
+ # so gate this one explicitly: earnings across the whole collective are
+ # admin-only. Applies to every format, including the CSV download.
+ controller do
+ before_action :require_admin!
+
+ # ActiveAdmin's `csv do ... end` DSL is defined on ResourceDSL and needs an
+ # ActiveRecord-backed collection, so a register_page can't use it. We get
+ # the same convention by hand: the index responds to .csv, which keeps the
+ # familiar /admin/leaderboard.csv URL and lets the standard
+ # "Download: CSV" footer link work with plain url_for.
+ def index
+ return super() unless request.format.csv?
+
+ limit = Stacks::Leaderboard.sanitize_limit(params[:limit])
+
+ send_data Stacks::Leaderboard.to_csv(limit: limit),
+ type: "text/csv; charset=utf-8",
+ disposition: %(attachment; filename="leaderboard-top-#{limit}-#{Date.today.iso8601}.csv")
+ end
+
+ private
+
+ def require_admin!
+ unless current_admin_user&.is_admin?
+ redirect_to admin_root_path, alert: "Admins only."
+ end
+ end
+ end
+
+ content title: "Leaderboard" do
+ limit = Stacks::Leaderboard.sanitize_limit(params[:limit])
+
+ render(partial: "leaderboard", locals: {
+ limit: limit,
+ months: Stacks::Leaderboard.call(limit: limit),
+ })
+ end
+end
diff --git a/app/views/admin/leaderboard/_leaderboard.html.erb b/app/views/admin/leaderboard/_leaderboard.html.erb
new file mode 100644
index 00000000..b819c368
--- /dev/null
+++ b/app/views/admin/leaderboard/_leaderboard.html.erb
@@ -0,0 +1,90 @@
+<%# Top X earners per month, styled to match the Money (payable QBO bills)
+ screen: a title_bar per group with the heading on the left and a pill on
+ the right, over a standard .index_table.index table. %>
+
+<%# Limit toggles, styled as the same nag-pill tab bar the Money page uses. %>
+
+ <% [3, 4, 5, 10, 20].each do |n| %>
+ <%= link_to admin_leaderboard_path(limit: n), style: "margin-right: 6px" do %>
+
+ <%= n %>
+
+ <% end %>
+ <% end %>
+
+
+<% if months.empty? %>
+
+
+
+
No contributor payouts recorded yet.
+
+
+
+<% else %>
+ <% months.each do |group| %>
+
+
+
<%= group.start_of_month.strftime("%B %Y") %>
+
+
+
+ <%= number_to_currency(group.average) %>
+ avg of top <%= group.entries.size %>
+
+
+
+
+ <%# Same markup ActiveAdmin's Arbre `panel` builder emits, so it picks up
+ the standard panel styling from an ERB template. %>
+
+
MSO Compensation
+
+
+
+ <% Stacks::Leaderboard.mso_compensation(group.average).each_with_index do |tier, index| %>
+ ">
+ | <%= tier.label %> (<%= tier.multiplier_label %>) |
+ <%= number_to_currency(tier.amount) %> |
+
+ <% end %>
+
+
+
+
+
+
+
+
+ | # |
+ Contributor |
+ Earnings |
+
+
+
+ <% group.entries.each_with_index do |entry, index| %>
+ ">
+ | <%= entry.rank %> |
+ <%= link_to entry.display_name, admin_contributor_path(entry.contributor_id) %> |
+ <%= number_to_currency(entry.amount) %> |
+
+ <% end %>
+ ">
+ |
+ Total |
+ <%= number_to_currency(group.total) %> |
+
+
+
+ <% end %>
+
+ <%# Mirrors the #index_footer > .download_links markup ActiveAdmin renders
+ beneath a standard index table, so the download reads as the convention
+ it is. Keeps the current ?limit= so you export exactly what you see. %>
+
+<% end %>
diff --git a/lib/stacks/leaderboard.rb b/lib/stacks/leaderboard.rb
new file mode 100644
index 00000000..913e78b4
--- /dev/null
+++ b/lib/stacks/leaderboard.rb
@@ -0,0 +1,174 @@
+# Ranks contributors by how much they actually earned in each month.
+#
+# "Earnings" here means ContributorPayout only. Trueups are deliberately
+# excluded: a Trueup is the mechanism that tops a leadership role up to the
+# average of the top earners, so counting it would let the very roles being
+# topped up appear at the top of the board they're benchmarked against.
+# Because Trueups live in their own table (rather than as a type column on a
+# shared ledger table), excluding them is structural — we simply never sum
+# them — and no filtering predicate is required.
+#
+# A contributor has one Ledger per Enterprise, so payouts are grouped by
+# ledgers.contributor_id to aggregate "across all their contributor ledgers".
+# Months are bucketed on invoice_passes.start_of_month, matching the canonical
+# dating used by ContributorPayout#effective_on_for_display.
+require "csv"
+
+module Stacks
+ class Leaderboard
+ DEFAULT_LIMIT = 5
+ MIN_LIMIT = 1
+ MAX_LIMIT = 50
+
+ CSV_HEADERS = [
+ "Month",
+ "Rank",
+ "Contributor",
+ "Earnings",
+ ].freeze
+
+ # Leadership compensation tiers. Each tier earns the month's average
+ # top-N earner times its multiplier — which is exactly why Trueups are
+ # excluded from the earnings above: a Trueup is the payment that lifts
+ # one of these roles up to the figure derived here, so letting it count
+ # as earnings would feed the benchmark back into itself.
+ # A uniform ladder: every tier is +0.15 above the one below it.
+ MSO_TIERS = [
+ ["Heads", BigDecimal("0.7")],
+ ["SVPs", BigDecimal("0.85")],
+ ["Chiefs", BigDecimal("1")],
+ ["Principals", BigDecimal("1.15")],
+ ].freeze
+
+ MSO_TIER_STEP = BigDecimal("0.15")
+
+ MsoTier = Struct.new(:label, :multiplier, :multiplier_label, :amount, keyword_init: true)
+
+ # What each leadership tier is paid for a month, given that month's
+ # average top-N earner.
+ def self.mso_compensation(average)
+ MSO_TIERS.map do |label, multiplier|
+ MsoTier.new(
+ label: label,
+ multiplier: multiplier,
+ multiplier_label: "#{multiplier.to_s("F").sub(/\.0+\z/, "")}x",
+ amount: average * multiplier
+ )
+ end
+ end
+
+ Entry = Struct.new(:rank, :contributor_id, :display_name, :amount, keyword_init: true)
+
+ # `average` is the mean across the listed entries only (not the whole
+ # collective) — i.e. the "average top-N earner", which is the figure the
+ # leadership compensation model multiplies against.
+ MonthGroup = Struct.new(:start_of_month, :entries, :total, :average, keyword_init: true)
+
+ # Coerces an untrusted ?limit= param into a sane integer.
+ def self.sanitize_limit(raw)
+ value = Integer(raw.to_s.strip, exception: false)
+ return DEFAULT_LIMIT if value.nil?
+ value.clamp(MIN_LIMIT, MAX_LIMIT)
+ end
+
+ def self.call(limit: DEFAULT_LIMIT)
+ new(limit: limit).call
+ end
+
+ # Flat, spreadsheet-friendly rendering: one row per ranked contributor.
+ # Month average and total are deliberately omitted — they're derivable from
+ # these rows, so repeating them would just be denormalised noise in a
+ # spreadsheet. Amounts are unformatted decimals (no currency symbols or
+ # thousands separators) so they land as numbers, not text.
+ def self.to_csv(limit: DEFAULT_LIMIT, months: nil)
+ groups = months || call(limit: limit)
+
+ CSV.generate do |csv|
+ csv << CSV_HEADERS
+ groups.each do |group|
+ group.entries.each do |entry|
+ csv << [
+ group.start_of_month.strftime("%Y-%m"),
+ entry.rank,
+ entry.display_name,
+ format("%.2f", entry.amount),
+ ]
+ end
+ end
+ end
+ end
+
+ def initialize(limit: DEFAULT_LIMIT)
+ @limit = limit
+ end
+
+ # Returns [MonthGroup] ordered most-recent month first.
+ def call
+ grouped = earnings_by_month_and_contributor
+ .select { |_key, amount| amount.to_d.positive? }
+ .group_by { |(month, _contributor_id), _amount| month }
+
+ ranked_by_month = grouped.transform_values do |pairs|
+ pairs
+ .sort_by { |(_month, _contributor_id), amount| -amount.to_d }
+ .first(@limit)
+ end
+
+ names = display_names_for(
+ ranked_by_month.values.flatten(1).map { |(_month, contributor_id), _amount| contributor_id }.uniq
+ )
+
+ ranked_by_month
+ .sort_by { |month, _pairs| month }
+ .reverse
+ .map do |month, pairs|
+ entries = pairs.each_with_index.map do |((_month, contributor_id), amount), index|
+ Entry.new(
+ rank: index + 1,
+ contributor_id: contributor_id,
+ display_name: names.fetch(contributor_id, "Contributor ##{contributor_id}"),
+ amount: amount.to_d
+ )
+ end
+
+ total = entries.sum(&:amount)
+
+ MonthGroup.new(
+ start_of_month: month,
+ entries: entries,
+ total: total,
+ average: entries.empty? ? BigDecimal(0) : (total / entries.size)
+ )
+ end
+ end
+
+ private
+
+ # => { [start_of_month, contributor_id] => summed_amount }
+ #
+ # ContributorPayout is acts_as_paranoid, so its default scope already
+ # excludes soft-deleted rows.
+ def earnings_by_month_and_contributor
+ ContributorPayout
+ .joins(:ledger, invoice_tracker: :invoice_pass)
+ .group("invoice_passes.start_of_month", "ledgers.contributor_id")
+ .sum("contributor_payouts.amount")
+ end
+
+ def display_names_for(contributor_ids)
+ return {} if contributor_ids.empty?
+
+ # Contributor has a default_scope that force-joins forecast_person and
+ # orders by email; unscoped keeps this lookup predictable.
+ Contributor
+ .unscoped
+ .where(id: contributor_ids)
+ .includes(:forecast_person)
+ .each_with_object({}) do |contributor, memo|
+ person = contributor.forecast_person
+ full_name = [person&.first_name, person&.last_name].map(&:presence).compact.join(" ")
+ memo[contributor.id] = full_name.presence || person&.email || "Contributor ##{contributor.id}"
+ end
+ end
+ end
+end
diff --git a/test/integration/admin_leaderboard_test.rb b/test/integration/admin_leaderboard_test.rb
new file mode 100644
index 00000000..d8bb9705
--- /dev/null
+++ b/test/integration/admin_leaderboard_test.rb
@@ -0,0 +1,180 @@
+require 'test_helper'
+
+class AdminLeaderboardTest < ActionDispatch::IntegrationTest
+ include Devise::Test::IntegrationHelpers
+
+ setup do
+ @admin = AdminUser.create!(
+ email: "admin-#{SecureRandom.hex(4)}@example.com",
+ password: 'password12345',
+ password_confirmation: 'password12345',
+ roles: ['admin']
+ )
+
+ @month = Date.new(2096, 5, 1)
+ @pass = InvoicePass.create!(start_of_month: @month)
+ client = ForecastClient.create!(
+ forecast_id: rand(1..2_000_000_000),
+ name: "Leaderboard Client #{SecureRandom.hex(2)}"
+ )
+ @tracker = InvoiceTracker.create!(
+ forecast_client: client,
+ invoice_pass: @pass,
+ qbo_account: qbo_accounts(:one)
+ )
+ end
+
+ def contributor_with_payout(email, amount)
+ person = ForecastPerson.create!(
+ forecast_id: rand(1..2_000_000_000),
+ email: email,
+ data: {}
+ )
+ contributor = Contributor.create!(forecast_person: person)
+ ContributorPayout.new(
+ ledger: Ledger.find_or_create_for(enterprise: enterprises(:sanctuary), contributor: contributor),
+ invoice_tracker: @tracker,
+ created_by: @admin,
+ amount: amount
+ ).save!(validate: false)
+ contributor
+ end
+
+ test 'renders the ledger with the month, ranked earners and amounts' do
+ alpha = contributor_with_payout('alpha@example.com', 900)
+ contributor_with_payout('beta@example.com', 400)
+ sign_in @admin
+
+ get '/admin/leaderboard'
+
+ assert_response :success
+ assert_includes response.body, 'May 2096', 'shows the month heading'
+ assert_includes response.body, 'alpha@example.com'
+ assert_includes response.body, 'beta@example.com'
+ assert_includes response.body, '$900.00'
+ assert_includes response.body, '$1,300.00', 'shows the month total'
+ assert_includes response.body, '$650.00', 'shows the average of the listed earners'
+ assert_includes response.body, 'avg of top 2'
+ assert_includes response.body, 'index_table', 'uses the shared ActiveAdmin table styling'
+ assert_includes response.body, 'nag pill complete', 'marks the active limit toggle'
+ [3, 4, 5, 10, 20].each do |n|
+ assert_includes response.body, %(href="/admin/leaderboard?limit=#{n}"),
+ "offers a limit toggle for #{n}"
+ end
+ assert_includes response.body,
+ %(alpha@example.com),
+ 'links each contributor through to their contributor page'
+ assert_includes response.body, 'MSO Compensation
',
+ 'each month gets an MSO Compensation panel'
+ assert_includes response.body, 'panel_contents',
+ 'the panel uses the standard ActiveAdmin panel markup'
+ # Month average is $650.00 (900 + 400, over 2 listed earners).
+ assert_includes response.body, 'Heads (0.7x)'
+ assert_includes response.body, '$455.00', 'Heads = 650 x 0.7'
+ assert_includes response.body, 'SVPs (0.85x)'
+ assert_includes response.body, '$552.50', 'SVPs = 650 x 0.85'
+ assert_includes response.body, 'Chiefs (1x)'
+ assert_includes response.body, 'Principals (1.15x)'
+ assert_includes response.body, '$747.50', 'Principals = 650 x 1.15'
+ assert_includes response.body, 'download_links',
+ 'renders the standard ActiveAdmin download footer'
+ assert_includes response.body, %(CSV),
+ 'download link uses the conventional .csv URL and carries the current limit'
+ end
+
+ test 'defaults to the top 5 and honors ?limit=' do
+ 7.times { |i| contributor_with_payout("earner#{i}@example.com", (i + 1) * 100) }
+ sign_in @admin
+
+ get '/admin/leaderboard'
+ assert_response :success
+ # Highest five are 700..300; the 200 and 100 earners fall outside the default.
+ assert_includes response.body, 'earner6@example.com'
+ assert_includes response.body, 'earner2@example.com'
+ refute_includes response.body, 'earner1@example.com', 'defaults to top 5'
+
+ get '/admin/leaderboard?limit=7'
+ assert_response :success
+ assert_includes response.body, 'earner0@example.com', 'limit=7 widens the board'
+ end
+
+ test 'clamps an out-of-range limit instead of erroring' do
+ contributor_with_payout('solo@example.com', 100)
+ sign_in @admin
+
+ get '/admin/leaderboard?limit=99999'
+ assert_response :success
+
+ get '/admin/leaderboard?limit=bogus'
+ assert_response :success
+ assert_includes response.body, 'solo@example.com'
+ end
+
+ test 'exports CSV as an attachment' do
+ contributor_with_payout('csvalpha@example.com', 900)
+ contributor_with_payout('csvbeta@example.com', 300)
+ sign_in @admin
+
+ get '/admin/leaderboard.csv'
+
+ assert_response :success
+ assert_match %r{text/csv}, response.content_type
+ assert_match /attachment; filename="leaderboard-top-5-\d{4}-\d{2}-\d{2}\.csv"/,
+ response.headers['Content-Disposition']
+
+ rows = CSV.parse(response.body)
+ assert_equal Stacks::Leaderboard::CSV_HEADERS, rows.first
+ alpha = rows.find { |r| r[2] == 'csvalpha@example.com' }
+ assert_equal '900.00', alpha[3]
+ assert_equal %w[Month Rank Contributor Earnings], rows.first,
+ 'no month average/total columns'
+ end
+
+ test 'CSV export honors ?limit=' do
+ 3.times { |i| contributor_with_payout("csvlim#{i}@example.com", (i + 1) * 100) }
+ sign_in @admin
+
+ get '/admin/leaderboard.csv?limit=1'
+
+ assert_response :success
+ body = CSV.parse(response.body).drop(1).select { |r| r[0] == @month.strftime('%Y-%m') }
+ assert_equal 1, body.size
+ assert_equal 'csvlim2@example.com', body.first[2]
+ end
+
+ test 'non-admins cannot export the CSV' do
+ contributor_with_payout('secret@example.com', 900)
+ non_admin = AdminUser.create!(
+ email: "plain-csv-#{SecureRandom.hex(4)}@example.com",
+ password: 'password12345',
+ password_confirmation: 'password12345',
+ roles: []
+ )
+ sign_in non_admin
+
+ get '/admin/leaderboard.csv'
+
+ # ActiveAdmin's authorize_access! runs first and answers a non-HTML format
+ # with 401 rather than a redirect. Either way the data must not be served,
+ # so assert the security property instead of a specific status.
+ refute_equal 200, response.status, 'must never serve the CSV to a non-admin'
+ refute_includes response.body.to_s, 'secret@example.com', 'must not leak earnings'
+ refute_includes response.body.to_s, Stacks::Leaderboard::CSV_HEADERS.join(','),
+ 'no CSV payload is emitted at all'
+ end
+
+ test 'non-admins are redirected away' do
+ non_admin = AdminUser.create!(
+ email: "plain-#{SecureRandom.hex(4)}@example.com",
+ password: 'password12345',
+ password_confirmation: 'password12345',
+ roles: []
+ )
+ sign_in non_admin
+
+ get '/admin/leaderboard'
+
+ assert_response :redirect
+ refute_includes(response.body.to_s, 'alpha@example.com')
+ end
+end
diff --git a/test/lib/stacks/leaderboard_test.rb b/test/lib/stacks/leaderboard_test.rb
new file mode 100644
index 00000000..3f291f98
--- /dev/null
+++ b/test/lib/stacks/leaderboard_test.rb
@@ -0,0 +1,224 @@
+require "test_helper"
+
+class Stacks::LeaderboardTest < ActiveSupport::TestCase
+ def setup
+ @admin = AdminUser.create!(
+ email: "leaderboard-#{SecureRandom.hex(3)}@sanctuary.computer",
+ password: "password"
+ )
+ @enterprise = enterprises(:sanctuary)
+ @other_enterprise = enterprises(:one)
+ @qbo_account = qbo_accounts(:one)
+
+ # Far-future month so this suite never collides with other tests or with
+ # the unique index on invoice_passes.start_of_month.
+ @month = Date.new(2097, 3, 1)
+ @pass = InvoicePass.create!(start_of_month: @month)
+ @client = ForecastClient.create!(
+ forecast_id: rand(1..2_000_000_000),
+ name: "Leaderboard Client #{SecureRandom.hex(2)}"
+ )
+ @tracker = InvoiceTracker.create!(
+ forecast_client: @client,
+ invoice_pass: @pass,
+ qbo_account: @qbo_account
+ )
+ end
+
+ def make_contributor(email)
+ person = ForecastPerson.create!(
+ forecast_id: rand(1..2_000_000_000),
+ email: email,
+ data: {}
+ )
+ Contributor.create!(forecast_person: person)
+ end
+
+ def ledger_for(contributor, enterprise = @enterprise)
+ Ledger.find_or_create_for(enterprise: enterprise, contributor: contributor)
+ end
+
+ # save(validate: false) sidesteps the 70%-of-invoice and new-deal domain
+ # validations, which are irrelevant to (and would obstruct) aggregation tests.
+ def payout!(contributor, amount, enterprise: @enterprise)
+ ContributorPayout.new(
+ ledger: ledger_for(contributor, enterprise),
+ invoice_tracker: @tracker,
+ created_by: @admin,
+ amount: amount
+ ).save!(validate: false)
+ end
+
+ def month_group
+ Stacks::Leaderboard.call(limit: 5).find { |g| g.start_of_month == @month }
+ end
+
+ test "ranks contributors by summed payouts, descending, honoring the limit" do
+ top = make_contributor("top@example.com")
+ middle = make_contributor("middle@example.com")
+ bottom = make_contributor("bottom@example.com")
+
+ payout!(middle, 300)
+ payout!(top, 500)
+ payout!(bottom, 100)
+
+ group = Stacks::Leaderboard.call(limit: 2).find { |g| g.start_of_month == @month }
+
+ assert_equal 2, group.entries.size, "limit should cap the number of entries"
+ assert_equal ["top@example.com", "middle@example.com"], group.entries.map(&:display_name)
+ assert_equal [1, 2], group.entries.map(&:rank)
+ assert_equal BigDecimal("500"), group.entries.first.amount
+ assert_equal BigDecimal("800"), group.total, "total reflects only the listed entries"
+ assert_equal BigDecimal("400"), group.average, "average is across the listed entries only"
+ end
+
+ test "average is the mean of the listed entries, not the whole collective" do
+ make_contributor("a@example.com").tap { |c| payout!(c, 900) }
+ make_contributor("b@example.com").tap { |c| payout!(c, 300) }
+ # A third earner that falls outside a limit of 2 must not drag the average.
+ make_contributor("c@example.com").tap { |c| payout!(c, 30) }
+
+ group = Stacks::Leaderboard.call(limit: 2).find { |g| g.start_of_month == @month }
+
+ assert_equal BigDecimal("1200"), group.total
+ assert_equal BigDecimal("600"), group.average
+ end
+
+ test "excludes Trueups from earnings" do
+ real_earner = make_contributor("earner@example.com")
+ topped_up = make_contributor("toppedup@example.com")
+
+ payout!(real_earner, 1_000)
+ payout!(topped_up, 10)
+
+ # A large Trueup must not lift this contributor up the board.
+ Trueup.new(
+ ledger: ledger_for(topped_up),
+ invoice_pass: @pass,
+ amount: 5_000
+ ).save!(validate: false)
+
+ group = month_group
+
+ assert_equal "earner@example.com", group.entries.first.display_name,
+ "the Trueup recipient must not outrank a genuine earner"
+
+ topped_up_entry = group.entries.find { |e| e.display_name == "toppedup@example.com" }
+ assert_equal BigDecimal("10"), topped_up_entry.amount,
+ "only the payout should count, not the Trueup"
+ assert_equal BigDecimal("1010"), group.total
+ end
+
+ test "sums a contributor's payouts across all of their ledgers" do
+ contributor = make_contributor("multi@example.com")
+
+ payout!(contributor, 100, enterprise: @enterprise)
+ payout!(contributor, 250, enterprise: @other_enterprise)
+
+ group = month_group
+ entry = group.entries.find { |e| e.display_name == "multi@example.com" }
+
+ assert_equal 1, group.entries.count { |e| e.display_name == "multi@example.com" },
+ "a contributor appears once, aggregated across ledgers"
+ assert_equal BigDecimal("350"), entry.amount
+ end
+
+ test "omits contributors with no positive earnings" do
+ earner = make_contributor("positive@example.com")
+ zeroed = make_contributor("zero@example.com")
+
+ payout!(earner, 100)
+ payout!(zeroed, 0)
+
+ group = month_group
+
+ assert_equal ["positive@example.com"], group.entries.map(&:display_name)
+ end
+
+ test "prefers a full name over the email when one is present" do
+ person = ForecastPerson.create!(
+ forecast_id: rand(1..2_000_000_000),
+ email: "named@example.com",
+ first_name: "Ada",
+ last_name: "Lovelace",
+ data: {}
+ )
+ contributor = Contributor.create!(forecast_person: person)
+ payout!(contributor, 42)
+
+ assert_equal "Ada Lovelace", month_group.entries.first.display_name
+ end
+
+ test "to_csv emits a header and one row per ranked contributor" do
+ make_contributor("first@example.com").tap { |c| payout!(c, 900) }
+ make_contributor("second@example.com").tap { |c| payout!(c, 300) }
+
+ rows = CSV.parse(Stacks::Leaderboard.to_csv(limit: 5))
+
+ assert_equal Stacks::Leaderboard::CSV_HEADERS, rows.first
+ row = rows.find { |r| r[2] == "first@example.com" }
+ assert_equal "2097-03", row[0], "month is ISO-ish for sorting"
+ assert_equal "1", row[1], "rank"
+ assert_equal "900.00", row[3], "earnings are plain decimals, not currency strings"
+ assert_equal 4, row.size, "no month average/total columns"
+ end
+
+ test "to_csv honors the limit" do
+ 5.times { |i| make_contributor("csv#{i}@example.com").tap { |c| payout!(c, (i + 1) * 100) } }
+
+ rows = CSV.parse(Stacks::Leaderboard.to_csv(limit: 2))
+ body = rows.drop(1).select { |r| r[0] == "2097-03" }
+
+ assert_equal 2, body.size
+ assert_equal ["csv4@example.com", "csv3@example.com"], body.map { |r| r[2] }
+ end
+
+ test "to_csv escapes names containing commas" do
+ person = ForecastPerson.create!(
+ forecast_id: rand(1..2_000_000_000),
+ email: "comma@example.com",
+ first_name: "Lovelace, Ada",
+ last_name: "",
+ data: {}
+ )
+ payout!(Contributor.create!(forecast_person: person), 100)
+
+ parsed = CSV.parse(Stacks::Leaderboard.to_csv(limit: 5))
+
+ assert_includes parsed.map { |r| r[2] }, "Lovelace, Ada",
+ "a comma in a name must survive a CSV round-trip"
+ end
+
+ test "mso_compensation scales the month average by each tier multiplier" do
+ tiers = Stacks::Leaderboard.mso_compensation(BigDecimal("1000"))
+
+ assert_equal ["Heads", "SVPs", "Chiefs", "Principals"], tiers.map(&:label)
+ assert_equal ["0.7x", "0.85x", "1x", "1.15x"], tiers.map(&:multiplier_label),
+ "1x renders without a trailing .0"
+ assert_equal [BigDecimal("700"), BigDecimal("850"), BigDecimal("1000"), BigDecimal("1150")],
+ tiers.map(&:amount)
+ end
+
+ test "the tier ladder steps by a uniform +0.15" do
+ steps = Stacks::Leaderboard::MSO_TIERS.map(&:last).each_cons(2).map { |a, b| b - a }
+
+ assert_equal [Stacks::Leaderboard::MSO_TIER_STEP] * steps.size, steps,
+ "every tier should sit one even step above the one below it"
+ end
+
+ test "mso_compensation handles a zero average" do
+ amounts = Stacks::Leaderboard.mso_compensation(BigDecimal(0)).map { |t| t.amount.to_i }
+ assert_equal [0] * Stacks::Leaderboard::MSO_TIERS.size, amounts
+ end
+
+ test "sanitize_limit defaults, clamps, and rejects junk" do
+ assert_equal 5, Stacks::Leaderboard.sanitize_limit(nil)
+ assert_equal 5, Stacks::Leaderboard.sanitize_limit("")
+ assert_equal 5, Stacks::Leaderboard.sanitize_limit("not-a-number")
+ assert_equal 3, Stacks::Leaderboard.sanitize_limit("3")
+ assert_equal 10, Stacks::Leaderboard.sanitize_limit(10)
+ assert_equal 1, Stacks::Leaderboard.sanitize_limit("0"), "clamps below the minimum"
+ assert_equal 1, Stacks::Leaderboard.sanitize_limit("-4"), "clamps negatives"
+ assert_equal 50, Stacks::Leaderboard.sanitize_limit("999"), "clamps above the maximum"
+ end
+end