Super-Admin Billing CSV — Stripe Enrichment Implementation Plan
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; replacebilling_csvbody; 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 extendspec/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(addsuper_admin.billing_csv_mailerkeys) -
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-levelsuper_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_csvaction; 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_csvaction
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;generatereturns a String consumed by job/mailer; mailerwith(super_admin:, csv_data:, month:)matches between Tasks 3 and 4; job passesmonth.iso8601(controller) →Date.parse(job) →strftime('%Y-%m')(mailer filename/subject), consistent. - No new dependencies; no infra/port/DB changes.