Super-Admin Billing CSV — Stripe Enrichment Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Enrich the super-admin analytics “Download CSV” export with per-account Stripe data (last payment in the selected month, next invoice, what the payment is for, trial status) and an explicit AI free/bought split, generated in a background job and emailed to the super admin.

Architecture: The billing_csv controller action stops rendering inline and instead enqueues SuperAdmin::BillingCsvJob, redirecting back with a flash. The job calls SuperAdmin::BillingCsvBuilder (all CSV logic, moved out of the controller, plus per-account Stripe enrichment via BillingService.get_account_subscription) and hands the result to a new plain SuperAdmin::BillingCsvMailer. Pricing/scope constants are extracted into a shared SuperAdmin::BillingConstants module used by both the controller and the builder (DRY).

Tech Stack: Rails 7, Ruby 3.2, RSpec, Sidekiq (ActiveJob), ActionMailer, Ruby CSV.


File Structure

  • Create app/services/super_admin/billing_constants.rb — shared pricing/scope constants.
  • Create app/services/super_admin/billing_csv_builder.rb — builds the CSV string (revenue + AI + Stripe columns, totals, footer).
  • Create app/jobs/super_admin/billing_csv_job.rb — orchestrates build + mail.
  • Create app/mailers/super_admin/billing_csv_mailer.rb — plain mailer to the super admin.
  • Create app/views/super_admin/billing_csv_mailer/billing_csv.html.erb — mail body.
  • Modify app/controllers/super_admin/analytics_controller.rb — include constants module; replace billing_csv body; delete moved CSV methods/constants.
  • Modify config/locales/en.yml — mailer subject + body strings.
  • Create specs under spec/services/super_admin/, spec/jobs/super_admin/, spec/mailers/super_admin/, and extend spec/controllers/super_admin/.

Task 1: Shared constants module + controller refactor

Files:

  • Create: app/services/super_admin/billing_constants.rb
  • Modify: app/controllers/super_admin/analytics_controller.rb:1-9 (remove literal constants, include module)
  • Test: spec/services/super_admin/billing_constants_spec.rb

  • Step 1: Write the failing test
# spec/services/super_admin/billing_constants_spec.rb
require 'rails_helper'

RSpec.describe SuperAdmin::BillingConstants do
  it 'exposes the pricing and scope constants' do
    expect(described_class::PLAN_PRICES).to include('personal' => 6, 'business' => 42)
    expect(described_class::OVERFLOW_PRICE).to eq(0.4)
    expect(described_class::INBOX_PRICE).to eq(2.0)
    expect(described_class::FREE_INBOXES).to include('team' => 8)
    expect(described_class::INTERNAL_ACCOUNT_IDS).to eq([1, 4])
  end

  it 'is included by the analytics controller' do
    expect(SuperAdmin::AnalyticsController.ancestors).to include(described_class)
  end
end
  • Step 2: Run test to verify it fails

Run: bundle exec rspec spec/services/super_admin/billing_constants_spec.rb Expected: FAIL with uninitialized constant SuperAdmin::BillingConstants.

  • Step 3: Create the constants module
# app/services/super_admin/billing_constants.rb
module SuperAdmin
  # Pricing + scope constants shared by the analytics dashboard and the billing
  # CSV builder. Single source of truth — do not duplicate these literals.
  module BillingConstants
    PLAN_PRICES = { 'personal' => 6, 'startup' => 16, 'team' => 26, 'business' => 42 }.freeze
    OVERFLOW_PRICE = 0.4
    INBOX_PRICE = 2.0
    FREE_INBOXES = { 'personal' => 3, 'startup' => 5, 'team' => 8, 'business' => 12 }.freeze
    INTERNAL_ACCOUNT_IDS = [1, 4].freeze
  end
end
  • Step 4: Include the module in the controller and remove the literal constants

In app/controllers/super_admin/analytics_controller.rb, replace lines 1-9:

class SuperAdmin::AnalyticsController < SuperAdmin::ApplicationController
  include ActionView::Helpers::NumberHelper
  include SuperAdmin::BillingConstants

  before_action :authorize_analytics_access!

Delete the now-removed literal constant definitions (PLAN_PRICES, OVERFLOW_PRICE, INBOX_PRICE, FREE_INBOXES, INTERNAL_ACCOUNT_IDS). Leave every other method untouched — they reference the constants unqualified, which still resolves through the included module.

  • Step 5: Run tests to verify they pass

Run: bundle exec rspec spec/services/super_admin/billing_constants_spec.rb Expected: PASS (2 examples).

  • Step 6: Run rubocop

Run: bundle exec rubocop app/services/super_admin/billing_constants.rb app/controllers/super_admin/analytics_controller.rb Expected: no offenses.

  • Step 7: Commit
git add app/services/super_admin/billing_constants.rb app/controllers/super_admin/analytics_controller.rb spec/services/super_admin/billing_constants_spec.rb
git commit -m "HODOR-1159: extract shared SuperAdmin::BillingConstants"

Task 2: BillingCsvBuilder (revenue + AI + Stripe columns)

Files:

  • Create: app/services/super_admin/billing_csv_builder.rb
  • Test: spec/services/super_admin/billing_csv_builder_spec.rb

  • Step 1: Write the failing test
# spec/services/super_admin/billing_csv_builder_spec.rb
require 'rails_helper'

RSpec.describe SuperAdmin::BillingCsvBuilder do
  let(:month) { Date.new(2026, 6, 1) }
  let(:super_admin_id) { 7 }
  let(:account) { create(:account, name: 'Acme', plan: 'team', limits: { 'agents' => 3, 'inboxes' => 10 }) }

  def build_for(acc)
    described_class.new(month: month, account_id: acc.id, super_admin_id: super_admin_id).generate
  end

  def row_for(csv_string, acc)
    CSV.parse(csv_string, headers: true).find { |r| r['Account ID'] == acc.id.to_s }
  end

  before do
    create(:ai_monthly_usage, account: account, period_start: month, plan_limit: 200, conversations_used: 250)
    allow(BillingService).to receive(:get_account_subscription).and_return(stripe_payload)
  end

  def stripe_payload(overrides = {})
    base = {
      'success' => true,
      'data' => {
        'hasBilling' => true,
        'subscription' => {
          'status' => 'active',
          'interval' => 'month',
          'productName' => 'Team',
          'trial' => { 'isTrial' => false, 'trialEnd' => nil }
        },
        'upcomingInvoice' => { 'amountDue' => 2600, 'currency' => 'usd', 'periodEnd' => Time.utc(2026, 7, 1).to_i },
        'paymentHistory' => [
          { 'amount' => 2600, 'currency' => 'usd', 'date' => Time.utc(2026, 6, 10).to_i }
        ]
      }
    }
    base.deep_merge(overrides)
  end

  it 'emits the full header row in order' do
    headers = CSV.parse(build_for(account)).first
    expect(headers).to eq(
      ['Account ID', 'Account Name', 'Plan', 'Agents', 'Subscription Revenue',
       'Additional Inboxes', 'Inbox Revenue', 'AI Conversations Used', 'AI Free Used',
       'AI Overflow', 'AI Overflow Cost', 'Total Revenue',
       'Has Billing', 'Subscription Status', 'In Trial', 'Trial Ends',
       'Stripe Product', 'Billing Interval', 'Last Payment (Month)', 'Last Payment Date',
       'Next Invoice Amount', 'Next Invoice Date']
    )
  end

  it 'computes revenue and AI free/bought split for the selected month' do
    row = row_for(build_for(account), account)
    expect(row['Agents']).to eq('3')
    expect(row['Subscription Revenue']).to eq('$78')          # 3 * 26
    expect(row['Additional Inboxes']).to eq('2')              # 10 - 8 free
    expect(row['Inbox Revenue']).to eq('$4.0')               # 2 * 2.0
    expect(row['AI Conversations Used']).to eq('250')
    expect(row['AI Free Used']).to eq('200')                  # min(250, 200)
    expect(row['AI Overflow']).to eq('50')                    # 250 - 200
    expect(row['AI Overflow Cost']).to eq('$20.0')           # 50 * 0.4
    expect(row['Total Revenue']).to eq('$102.0')             # 78 + 4 + 20
  end

  it 'maps live Stripe subscription fields' do
    row = row_for(build_for(account), account)
    expect(row['Has Billing']).to eq('Yes')
    expect(row['Subscription Status']).to eq('active')
    expect(row['In Trial']).to eq('No')
    expect(row['Stripe Product']).to eq('Team')
    expect(row['Billing Interval']).to eq('Monthly')
    expect(row['Next Invoice Amount']).to eq('26.00 USD')
    expect(row['Next Invoice Date']).to eq('2026-07-01')
  end

  it 'shows last payment only when it falls in the selected month' do
    row = row_for(build_for(account), account)
    expect(row['Last Payment (Month)']).to eq('26.00 USD')
    expect(row['Last Payment Date']).to eq('2026-06-10')
  end

  it 'blanks last payment when the only payment is outside the selected month' do
    allow(BillingService).to receive(:get_account_subscription).and_return(
      stripe_payload('data' => { 'paymentHistory' => [{ 'amount' => 2600, 'currency' => 'usd', 'date' => Time.utc(2026, 5, 9).to_i }] })
    )
    row = row_for(build_for(account), account)
    expect(row['Last Payment (Month)']).to eq('')
    expect(row['Last Payment Date']).to eq('')
  end

  it 'reports trial state and end date' do
    allow(BillingService).to receive(:get_account_subscription).and_return(
      stripe_payload('data' => { 'subscription' => { 'trial' => { 'isTrial' => true, 'trialEnd' => Time.utc(2026, 6, 20).to_i } } })
    )
    row = row_for(build_for(account), account)
    expect(row['In Trial']).to eq('Yes')
    expect(row['Trial Ends']).to eq('2026-06-20')
  end

  it 'handles an account with no Stripe billing' do
    allow(BillingService).to receive(:get_account_subscription).and_return('success' => true, 'data' => { 'hasBilling' => false })
    row = row_for(build_for(account), account)
    expect(row['Has Billing']).to eq('No')
    expect(row['Subscription Status']).to eq('')
    expect(row['In Trial']).to eq('')
    expect(row['Stripe Product']).to eq('')
  end

  it 'marks Stripe columns with ERROR when the billing service fails' do
    allow(BillingService).to receive(:get_account_subscription).and_raise(StandardError.new('billing down'))
    allow(Rails.logger).to receive(:error)
    row = row_for(build_for(account), account)
    stripe_headers = ['Has Billing', 'Subscription Status', 'In Trial', 'Trial Ends',
                      'Stripe Product', 'Billing Interval', 'Last Payment (Month)',
                      'Last Payment Date', 'Next Invoice Amount', 'Next Invoice Date']
    stripe_headers.each { |h| expect(row[h]).to eq('ERROR') }
    # Revenue/AI columns are still populated.
    expect(row['Subscription Revenue']).to eq('$78')
    expect(Rails.logger).to have_received(:error).with(/stripe fetch failed account=#{account.id}/)
  end

  it 'appends a TOTAL row (Stripe columns blank) and the footer note' do
    csv = build_for(account)
    parsed = CSV.parse(csv)
    total = parsed.find { |r| r[1] == 'TOTAL' }
    expect(total[3]).to eq('3')          # agents summed
    expect(total[12..21]).to all(satisfy { |c| c.nil? || c == '' })
    expect(csv).to include('reflect live state at export time')
  end
end
  • Step 2: Run test to verify it fails

Run: bundle exec rspec spec/services/super_admin/billing_csv_builder_spec.rb Expected: FAIL with uninitialized constant SuperAdmin::BillingCsvBuilder.

  • Step 3: Implement the builder
# app/services/super_admin/billing_csv_builder.rb
require 'csv'

module SuperAdmin
  # Builds the per-account billing CSV (revenue + AI usage + live Stripe data)
  # for the analytics export. Pure object: hand it a month, an optional account
  # filter, and the acting super-admin id; call #generate for the CSV string.
  class BillingCsvBuilder
    include SuperAdmin::BillingConstants

    HEADERS = [
      'Account ID', 'Account Name', 'Plan', 'Agents', 'Subscription Revenue',
      'Additional Inboxes', 'Inbox Revenue', 'AI Conversations Used', 'AI Free Used',
      'AI Overflow', 'AI Overflow Cost', 'Total Revenue',
      'Has Billing', 'Subscription Status', 'In Trial', 'Trial Ends',
      'Stripe Product', 'Billing Interval', 'Last Payment (Month)', 'Last Payment Date',
      'Next Invoice Amount', 'Next Invoice Date'
    ].freeze

    # Indices of the $-prefixed revenue columns.
    MONEY_INDICES = [4, 6, 10, 11].freeze
    # Numeric columns summed in the TOTAL row (Agents .. Total Revenue).
    TOTALED_INDICES = (3..11).freeze
    STRIPE_COL_COUNT = 10
    INTERVAL_LABELS = { 'month' => 'Monthly', 'year' => 'Annual' }.freeze
    FOOTER_NOTE = 'Stripe subscription fields (trial, next invoice, product, status) ' \
                  'reflect live state at export time, not the selected month.'

    def initialize(month:, account_id:, super_admin_id:)
      @month = month
      @account_id = account_id
      @super_admin_id = super_admin_id
    end

    def generate
      rows = account_rows
      CSV.generate do |csv|
        csv << HEADERS
        rows.each { |row| csv << format_row(row) }
        csv << format_row(totals_row(rows))
        csv << []
        csv << ["Period: #{@month.strftime('%B %Y')}", "Snapshot: #{Time.current.strftime('%Y-%m-%d %H:%M:%S %Z')}"]
        csv << [FOOTER_NOTE]
      end
    end

    private

    def account_rows
      accounts = base_accounts_scope.select(:id, :name, :plan, :limits).order(:name)
      ai_usage = AiMonthlyUsage
                 .where(period_start: @month, account_id: accounts.map(&:id))
                 .index_by(&:account_id)
      accounts.map { |account| build_row(account, ai_usage[account.id]) }
    end

    def base_accounts_scope
      scope = Account.active.where.not(id: INTERNAL_ACCOUNT_IDS)
      scope = scope.where(id: @account_id) if @account_id
      scope
    end

    def build_row(account, usage) # rubocop:disable Metrics/AbcSize
      agents = (account.limits['agents'] || 0).to_i
      sub_rev = agents * PLAN_PRICES[account.plan].to_i
      inboxes_bought = (account.limits['inboxes'] || 0).to_i
      billable_inboxes = [inboxes_bought - FREE_INBOXES.fetch(account.plan, 0), 0].max
      inbox_rev = (billable_inboxes * INBOX_PRICE).round(2)
      used = usage&.conversations_used.to_i
      limit = usage&.plan_limit.to_i
      free_used = [used, limit].min
      overflow = [used - limit, 0].max
      overflow_cost = (overflow * OVERFLOW_PRICE).round(2)

      [account.id, account.name, account.plan, agents, sub_rev,
       billable_inboxes, inbox_rev, used, free_used, overflow, overflow_cost,
       (sub_rev + inbox_rev + overflow_cost).round(2)] + stripe_columns(account)
    end

    def stripe_columns(account)
      data = fetch_stripe(account.id)
      return Array.new(STRIPE_COL_COUNT, 'ERROR') if data == :error

      data ||= {}
      sub = data['subscription']
      payment = last_payment_in_month(data)
      invoice = data['upcomingInvoice']

      [
        data['hasBilling'] ? 'Yes' : 'No',
        sub ? sub['status'].to_s : '',
        sub ? (sub.dig('trial', 'isTrial') ? 'Yes' : 'No') : '',
        sub ? fmt_date(sub.dig('trial', 'trialEnd')) : '',
        sub ? sub['productName'].to_s : '',
        sub ? (INTERVAL_LABELS[sub['interval']] || sub['interval'].to_s) : '',
        payment ? fmt_money(payment['amount'], payment['currency']) : '',
        payment ? fmt_date(payment['date']) : '',
        invoice ? fmt_money(invoice['amountDue'], invoice['currency']) : '',
        invoice ? fmt_date(invoice['periodEnd']) : ''
      ]
    end

    def fetch_stripe(account_id)
      response = BillingService.get_account_subscription(account_id, @super_admin_id)
      response['data']
    rescue StandardError => e
      Rails.logger.error("[BillingCsv] stripe fetch failed account=#{account_id}: #{e.message}")
      :error
    end

    def last_payment_in_month(data)
      history = data['paymentHistory'] || []
      range = @month.beginning_of_month..@month.end_of_month
      history.find do |inv|
        inv['date'].present? && range.cover?(Time.zone.at(inv['date'].to_i).to_date)
      end
    end

    def fmt_money(cents, currency)
      return '' if cents.nil?

      "#{format('%.2f', cents.to_i / 100.0)} #{currency.to_s.upcase}"
    end

    def fmt_date(unix)
      return '' if unix.blank?

      Time.zone.at(unix.to_i).strftime('%Y-%m-%d')
    end

    def format_row(row)
      row.each_with_index.map { |val, i| MONEY_INDICES.include?(i) ? "$#{val}" : val }
    end

    def totals_row(rows)
      totals = ['', 'TOTAL', '']
      TOTALED_INDICES.each { |i| totals << rows.sum { |r| r[i] }.round(2) }
      totals + Array.new(STRIPE_COL_COUNT, '')
    end
  end
end
  • Step 4: Run tests to verify they pass

Run: bundle exec rspec spec/services/super_admin/billing_csv_builder_spec.rb Expected: PASS (all examples).

  • Step 5: Run rubocop

Run: bundle exec rubocop app/services/super_admin/billing_csv_builder.rb Expected: no offenses (the Metrics/AbcSize disable on build_row is intentional and mirrors the original controller method).

  • Step 6: Commit
git add app/services/super_admin/billing_csv_builder.rb spec/services/super_admin/billing_csv_builder_spec.rb
git commit -m "HODOR-1159: add SuperAdmin::BillingCsvBuilder with Stripe enrichment"

Task 3: BillingCsvMailer + view + locale

Files:

  • Create: app/mailers/super_admin/billing_csv_mailer.rb
  • Create: app/views/super_admin/billing_csv_mailer/billing_csv.html.erb
  • Modify: config/locales/en.yml (add super_admin.billing_csv_mailer keys)
  • Test: spec/mailers/super_admin/billing_csv_mailer_spec.rb

  • Step 1: Write the failing test
# spec/mailers/super_admin/billing_csv_mailer_spec.rb
require 'rails_helper'

RSpec.describe SuperAdmin::BillingCsvMailer do
  let(:super_admin) { create(:super_admin, email: 'ops@example.com') }

  let(:mail) do
    described_class.with(
      super_admin: super_admin,
      csv_data: "a,b\n1,2\n",
      month: '2026-06'
    ).billing_csv
  end

  it 'sends to the super admin email with the subject' do
    expect(mail.to).to eq(['ops@example.com'])
    expect(mail.subject).to eq(I18n.t('super_admin.billing_csv_mailer.subject', month: '2026-06'))
  end

  it 'attaches the CSV named for the month' do
    attachment = mail.attachments['billing_2026-06.csv']
    expect(attachment).to be_present
    expect(attachment.body.to_s).to eq("a,b\n1,2\n")
  end
end
  • Step 2: Run test to verify it fails

Run: bundle exec rspec spec/mailers/super_admin/billing_csv_mailer_spec.rb Expected: FAIL with uninitialized constant SuperAdmin::BillingCsvMailer.

  • Step 3: Implement the mailer
# app/mailers/super_admin/billing_csv_mailer.rb
class SuperAdmin::BillingCsvMailer < ApplicationMailer
  # Plain (non-account-branded) mail: the export spans many accounts, so the
  # account layout/UserDrop context does not apply.
  layout false

  def billing_csv
    super_admin = params[:super_admin]
    @month = params[:month]
    attachments["billing_#{@month}.csv"] = params[:csv_data]

    mail(
      to: super_admin.email,
      subject: I18n.t('super_admin.billing_csv_mailer.subject', month: @month)
    )
  end
end
  • Step 4: Implement the mail body
<%# app/views/super_admin/billing_csv_mailer/billing_csv.html.erb %>
<p><%= t('super_admin.billing_csv_mailer.greeting') %></p>
<p><%= t('super_admin.billing_csv_mailer.body', month: @month) %></p>
  • Step 5: Add locale strings

In config/locales/en.yml, under the top-level en: key add (place near other backend namespaces; keep alphabetical neighbours intact):

  super_admin:
    billing_csv_mailer:
      subject: "Billing CSV for %{month}"
      greeting: "Hello,"
      body: "Your billing CSV export for %{month} is attached."

If an en: super_admin: key already exists, merge these children into it instead of adding a duplicate top-level super_admin: mapping.

  • Step 6: Run tests to verify they pass

Run: bundle exec rspec spec/mailers/super_admin/billing_csv_mailer_spec.rb Expected: PASS (2 examples).

  • Step 7: Run rubocop

Run: bundle exec rubocop app/mailers/super_admin/billing_csv_mailer.rb Expected: no offenses.

  • Step 8: Commit
git add app/mailers/super_admin/billing_csv_mailer.rb app/views/super_admin/billing_csv_mailer/billing_csv.html.erb config/locales/en.yml spec/mailers/super_admin/billing_csv_mailer_spec.rb
git commit -m "HODOR-1159: add SuperAdmin::BillingCsvMailer"

Task 4: BillingCsvJob

Files:

  • Create: app/jobs/super_admin/billing_csv_job.rb
  • Test: spec/jobs/super_admin/billing_csv_job_spec.rb

  • Step 1: Write the failing test
# spec/jobs/super_admin/billing_csv_job_spec.rb
require 'rails_helper'

RSpec.describe SuperAdmin::BillingCsvJob do
  subject(:job) { described_class }

  let(:super_admin) { create(:super_admin) }
  let(:builder) { instance_double(SuperAdmin::BillingCsvBuilder, generate: "csv-bytes") }
  let(:mailer) { instance_double(ActionMailer::MessageDelivery, deliver_now: true) }

  before do
    allow(SuperAdmin::BillingCsvBuilder).to receive(:new).and_return(builder)
    allow(SuperAdmin::BillingCsvMailer).to receive(:with).and_return(double(billing_csv: mailer)) # rubocop:disable RSpec/VerifiedDoubles
  end

  it 'builds the CSV for the parsed month, account filter and admin id' do
    job.perform_now('2026-06-01', 42, super_admin.id)

    expect(SuperAdmin::BillingCsvBuilder).to have_received(:new).with(
      month: Date.new(2026, 6, 1), account_id: 42, super_admin_id: super_admin.id
    )
  end

  it 'mails the generated CSV to the super admin' do
    job.perform_now('2026-06-01', nil, super_admin.id)

    expect(SuperAdmin::BillingCsvMailer).to have_received(:with).with(
      super_admin: super_admin, csv_data: 'csv-bytes', month: '2026-06'
    )
    expect(mailer).to have_received(:deliver_now)
  end
end
  • Step 2: Run test to verify it fails

Run: bundle exec rspec spec/jobs/super_admin/billing_csv_job_spec.rb Expected: FAIL with uninitialized constant SuperAdmin::BillingCsvJob.

  • Step 3: Implement the job
# app/jobs/super_admin/billing_csv_job.rb
class SuperAdmin::BillingCsvJob < ApplicationJob
  queue_as :default

  def perform(month_str, account_id, super_admin_id)
    super_admin = SuperAdmin.find(super_admin_id)
    month = Date.parse(month_str)

    csv_data = with_replica_database do
      SuperAdmin::BillingCsvBuilder.new(
        month: month, account_id: account_id, super_admin_id: super_admin_id
      ).generate
    end

    SuperAdmin::BillingCsvMailer.with(
      super_admin: super_admin,
      csv_data: csv_data,
      month: month.strftime('%Y-%m')
    ).billing_csv.deliver_now
  end
end
  • Step 4: Run tests to verify they pass

Run: bundle exec rspec spec/jobs/super_admin/billing_csv_job_spec.rb Expected: PASS (2 examples).

  • Step 5: Run rubocop

Run: bundle exec rubocop app/jobs/super_admin/billing_csv_job.rb Expected: no offenses.

  • Step 6: Commit
git add app/jobs/super_admin/billing_csv_job.rb spec/jobs/super_admin/billing_csv_job_spec.rb
git commit -m "HODOR-1159: add SuperAdmin::BillingCsvJob"

Task 5: Controller billing_csv → enqueue + redirect

Files:

  • Modify: app/controllers/super_admin/analytics_controller.rb (billing_csv action; delete moved CSV methods/constants)
  • Test: spec/controllers/super_admin/analytics_controller_billing_csv_spec.rb

  • Step 1: Write the failing test
# spec/controllers/super_admin/analytics_controller_billing_csv_spec.rb
require 'rails_helper'

# Request specs for authenticated super_admin are disabled across this suite,
# so we exercise the action method directly (mirrors accounts_controller_billing_spec).
RSpec.describe SuperAdmin::AnalyticsController do
  let(:controller_instance) { described_class.new }
  let(:super_admin) { instance_double(SuperAdmin, id: 7) }

  before do
    allow(controller_instance).to receive_messages(
      current_super_admin: super_admin,
      params: ActionController::Parameters.new(month: '2026-06', account_id: '42')
    )
    allow(controller_instance).to receive(:redirect_to)
    allow(SuperAdmin::BillingCsvJob).to receive(:perform_later)
  end

  describe '#billing_csv' do
    it 'enqueues the job with normalized month, account filter and admin id' do
      controller_instance.billing_csv

      expect(SuperAdmin::BillingCsvJob).to have_received(:perform_later).with('2026-06-01', 42, 7)
    end

    it 'passes nil account filter when none is selected' do
      allow(controller_instance).to receive(:params).and_return(ActionController::Parameters.new(month: '2026-06'))

      controller_instance.billing_csv

      expect(SuperAdmin::BillingCsvJob).to have_received(:perform_later).with('2026-06-01', nil, 7)
    end

    it 'redirects back to analytics with a notice' do
      controller_instance.billing_csv

      expect(controller_instance).to have_received(:redirect_to).with(anything, hash_including(notice: a_string_matching(/emailed/)))
    end
  end
end
  • Step 2: Run test to verify it fails

Run: bundle exec rspec spec/controllers/super_admin/analytics_controller_billing_csv_spec.rb Expected: FAIL — current billing_csv calls send_data, not perform_later/redirect_to.

  • Step 3: Replace the billing_csv action

In app/controllers/super_admin/analytics_controller.rb, replace the existing billing_csv method (lines ~26-32) with:

  def billing_csv
    month = parse_month(params[:month])
    account_id = params[:account_id].presence&.to_i
    SuperAdmin::BillingCsvJob.perform_later(month.iso8601, account_id, current_super_admin.id)

    redirect_to super_admin_analytics_path(month: params[:month], account_id: params[:account_id]),
                notice: 'Billing CSV is being generated and will be emailed to you.' # rubocop:disable Rails/I18nLocaleTexts
  end
  • Step 4: Delete the now-unused CSV methods/constants from the controller

Remove these (all moved into BillingCsvBuilder): BILLING_CSV_HEADERS, BILLING_MONEY_INDICES, fetch_per_account_billing, build_account_billing_row, format_billing_row, billing_totals_row. Leave parse_month, fetch_available_months, base_accounts_scope, and all dashboard (index) helpers intact — index still uses them.

  • Step 5: Run tests to verify they pass

Run: bundle exec rspec spec/controllers/super_admin/analytics_controller_billing_csv_spec.rb Expected: PASS (3 examples).

  • Step 6: Run rubocop

Run: bundle exec rubocop app/controllers/super_admin/analytics_controller.rb Expected: no offenses.

  • Step 7: Commit
git add app/controllers/super_admin/analytics_controller.rb spec/controllers/super_admin/analytics_controller_billing_csv_spec.rb
git commit -m "HODOR-1159: billing_csv enqueues background job + emails report"

Task 6: Full verification

  • Step 1: Run the full set of new/changed specs

Run:

bundle exec rspec \
  spec/services/super_admin/billing_constants_spec.rb \
  spec/services/super_admin/billing_csv_builder_spec.rb \
  spec/mailers/super_admin/billing_csv_mailer_spec.rb \
  spec/jobs/super_admin/billing_csv_job_spec.rb \
  spec/controllers/super_admin/analytics_controller_billing_csv_spec.rb \
  spec/controllers/super_admin/accounts_controller_billing_spec.rb \
  spec/controllers/super_admin/concerns/analytics_access_spec.rb

Expected: all PASS.

  • Step 2: Rubocop on every touched Ruby file

Run:

bundle exec rubocop \
  app/services/super_admin/billing_constants.rb \
  app/services/super_admin/billing_csv_builder.rb \
  app/mailers/super_admin/billing_csv_mailer.rb \
  app/jobs/super_admin/billing_csv_job.rb \
  app/controllers/super_admin/analytics_controller.rb

Expected: no offenses.

  • Step 3: Manual smoke (optional, dev env)

Start the app (yarn start:dev), open the super-admin analytics page as an allowed super admin, click Download CSV → expect a flash “Billing CSV is being generated and will be emailed to you.” and an email (check the dev mailbox / letter_opener) with billing_<YYYY-MM>.csv attached containing the new columns.


Self-Review Notes

  • Spec coverage: background-job UX (Task 5), builder + all Stripe columns + AI free split + last-payment-month + error resilience + totals/footer (Task 2), plain mailer to super admin (Task 3), job orchestration + replica reads (Task 4), shared constants/DRY (Task 1). All spec sections mapped.
  • Live-state caveat: documented via FOOTER_NOTE (Task 2) and asserted.
  • Type consistency: builder ctor (month:, account_id:, super_admin_id:) used identically in Task 4; generate returns a String consumed by job/mailer; mailer with(super_admin:, csv_data:, month:) matches between Tasks 3 and 4; job passes month.iso8601 (controller) → Date.parse (job) → strftime('%Y-%m') (mailer filename/subject), consistent.
  • No new dependencies; no infra/port/DB changes.