SaaS usage reporting (#2690)
* Add saas:usage_report rake task and extract Subscription.paid scope Add a rake task to generate a CSV usage report with per-account data: Queenbee ID, sign up date, paid date, card count, storage used, and last active date. Extract the paid subscriptions query from Admin::StatsController into an Account::Subscription.paid scope so both the controller and the new rake task can share it. * Add comped and account name columns to usage report * Update saas/lib/tasks/fizzy/usage_report.rake Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Preload storage_total to avoid N+1 in usage report * Batch last_active_at queries to avoid per-account aggregates * Add tests for Account::Subscription.paid scope * Update saas/app/models/account/subscription.rb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Aggregate paid dates in SQL instead of loading all subscriptions * Fix paid scope to derive plan keys from Plan.all instead of PLANS hash * Move paid dates and comped lookups into per-batch queries * Materialize batch IDs to avoid cross-database subquery SaasRecord models live on a separate database (fizzy_saas) that doesn't have the accounts table. Using batch.select(:id) generated a subquery that ran on the saas database, causing a table-not-found error. Using pluck(:id) materializes the IDs into an array instead. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -10,6 +10,7 @@ gem "queenbee", bc: "queenbee-plugin"
|
|||||||
gem "fizzy-saas", path: "saas"
|
gem "fizzy-saas", path: "saas"
|
||||||
gem "console1984", bc: "console1984"
|
gem "console1984", bc: "console1984"
|
||||||
gem "audits1984", bc: "audits1984", branch: "flavorjones/coworker-api"
|
gem "audits1984", bc: "audits1984", branch: "flavorjones/coworker-api"
|
||||||
|
gem "csv"
|
||||||
|
|
||||||
# Native push notifications (iOS/Android)
|
# Native push notifications (iOS/Android)
|
||||||
gem "action_push_native"
|
gem "action_push_native"
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ GEM
|
|||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
|
csv (3.3.5)
|
||||||
date (3.5.1)
|
date (3.5.1)
|
||||||
debug (1.11.1)
|
debug (1.11.1)
|
||||||
irb (~> 1.10)
|
irb (~> 1.10)
|
||||||
@@ -720,6 +721,7 @@ DEPENDENCIES
|
|||||||
bundler-audit
|
bundler-audit
|
||||||
capybara
|
capybara
|
||||||
console1984!
|
console1984!
|
||||||
|
csv
|
||||||
debug
|
debug
|
||||||
faker
|
faker
|
||||||
fizzy-saas!
|
fizzy-saas!
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ class Admin::StatsController < AdminController
|
|||||||
@accounts_last_7_days = Account.where(created_at: 7.days.ago..).count
|
@accounts_last_7_days = Account.where(created_at: 7.days.ago..).count
|
||||||
@accounts_last_24_hours = Account.where(created_at: 24.hours.ago..).count
|
@accounts_last_24_hours = Account.where(created_at: 24.hours.ago..).count
|
||||||
|
|
||||||
@paid_accounts_total = paid_subscriptions.distinct.count(:account_id)
|
@paid_accounts_total = Account::Subscription.paid.distinct.count(:account_id)
|
||||||
@paid_accounts_last_7_days = paid_subscriptions.where(created_at: 7.days.ago..).distinct.count(:account_id)
|
@paid_accounts_last_7_days = Account::Subscription.paid.where(created_at: 7.days.ago..).distinct.count(:account_id)
|
||||||
@paid_accounts_last_24_hours = paid_subscriptions.where(created_at: 24.hours.ago..).distinct.count(:account_id)
|
@paid_accounts_last_24_hours = Account::Subscription.paid.where(created_at: 24.hours.ago..).distinct.count(:account_id)
|
||||||
|
|
||||||
@identities_total = Identity.count
|
@identities_total = Identity.count
|
||||||
@identities_last_7_days = Identity.where(created_at: 7.days.ago..).count
|
@identities_last_7_days = Identity.where(created_at: 7.days.ago..).count
|
||||||
@@ -21,12 +21,4 @@ class Admin::StatsController < AdminController
|
|||||||
|
|
||||||
@recent_accounts = Account.order(created_at: :desc).limit(10)
|
@recent_accounts = Account.order(created_at: :desc).limit(10)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
def paid_subscriptions
|
|
||||||
Account::Subscription
|
|
||||||
.where(status: %w[active trialing past_due])
|
|
||||||
.where(plan_key: %w[monthly_v1 monthly_extra_storage_v1])
|
|
||||||
.where.not(account_id: Account::BillingWaiver.select(:account_id))
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ class Account::Subscription < SaasRecord
|
|||||||
|
|
||||||
enum :status, %w[ active past_due unpaid canceled incomplete incomplete_expired trialing paused ].index_by(&:itself)
|
enum :status, %w[ active past_due unpaid canceled incomplete incomplete_expired trialing paused ].index_by(&:itself)
|
||||||
|
|
||||||
|
scope :paid, -> {
|
||||||
|
where(status: %w[active trialing past_due])
|
||||||
|
.where(plan_key: Plan.all.select(&:paid?).map(&:key))
|
||||||
|
.where.not(account_id: Account::BillingWaiver.select(:account_id))
|
||||||
|
}
|
||||||
|
|
||||||
validates :plan_key, presence: true, inclusion: { in: Plan::PLANS.keys.map(&:to_s) }
|
validates :plan_key, presence: true, inclusion: { in: Plan::PLANS.keys.map(&:to_s) }
|
||||||
|
|
||||||
delegate :paid?, to: :plan
|
delegate :paid?, to: :plan
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
require "csv"
|
||||||
|
|
||||||
|
namespace :saas do
|
||||||
|
desc "Generate a CSV usage report for all active accounts"
|
||||||
|
task usage_report: :environment do
|
||||||
|
output_path = Rails.root.join("tmp/usage_report.csv")
|
||||||
|
|
||||||
|
CSV.open(output_path, "w") do |csv|
|
||||||
|
csv << [ "Queenbee ID", "Account Name", "Sign Up Date", "Paid Date", "Comped", "Card Count", "Storage Used (Bytes)", "Last Active" ]
|
||||||
|
|
||||||
|
Account.active.includes(:storage_total).in_batches do |batch|
|
||||||
|
batch_ids = batch.pluck(:id)
|
||||||
|
paid_dates = Account::Subscription.paid.where(account_id: batch_ids)
|
||||||
|
.group(:account_id).minimum(:created_at)
|
||||||
|
comped_account_ids = Account::BillingWaiver.where(account_id: batch_ids)
|
||||||
|
.pluck(:account_id).to_set
|
||||||
|
last_active_dates = Card.where(account_id: batch_ids)
|
||||||
|
.group(:account_id).maximum(:last_active_at)
|
||||||
|
|
||||||
|
batch.each do |account|
|
||||||
|
csv << [
|
||||||
|
account.external_account_id,
|
||||||
|
account.name,
|
||||||
|
account.created_at.to_date,
|
||||||
|
paid_dates[account.id]&.to_date,
|
||||||
|
comped_account_ids.include?(account.id),
|
||||||
|
account.cards_count,
|
||||||
|
account.bytes_used,
|
||||||
|
last_active_dates[account.id]&.to_date
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
puts "Report written to #{output_path}"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -16,6 +16,57 @@ class Account::SubscriptionTest < ActiveSupport::TestCase
|
|||||||
assert_not Account::Subscription.new(plan_key: "free_v1", status: "active").paid?
|
assert_not Account::Subscription.new(plan_key: "free_v1", status: "active").paid?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "paid scope includes active paid subscriptions" do
|
||||||
|
subscription = Account::Subscription.create!(
|
||||||
|
account: accounts(:initech), plan_key: "monthly_v1", status: "active",
|
||||||
|
stripe_customer_id: "cus_paid"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_includes Account::Subscription.paid, subscription
|
||||||
|
end
|
||||||
|
|
||||||
|
test "paid scope includes trialing and past_due subscriptions" do
|
||||||
|
trialing = Account::Subscription.create!(
|
||||||
|
account: accounts(:initech), plan_key: "monthly_v1", status: "trialing",
|
||||||
|
stripe_customer_id: "cus_trialing"
|
||||||
|
)
|
||||||
|
past_due = Account::Subscription.create!(
|
||||||
|
account: accounts(:acme), plan_key: "monthly_extra_storage_v1", status: "past_due",
|
||||||
|
stripe_customer_id: "cus_past_due"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_includes Account::Subscription.paid, trialing
|
||||||
|
assert_includes Account::Subscription.paid, past_due
|
||||||
|
end
|
||||||
|
|
||||||
|
test "paid scope excludes free plan subscriptions" do
|
||||||
|
subscription = Account::Subscription.create!(
|
||||||
|
account: accounts(:initech), plan_key: "free_v1", status: "active",
|
||||||
|
stripe_customer_id: "cus_free"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_not_includes Account::Subscription.paid, subscription
|
||||||
|
end
|
||||||
|
|
||||||
|
test "paid scope excludes canceled subscriptions" do
|
||||||
|
subscription = Account::Subscription.create!(
|
||||||
|
account: accounts(:initech), plan_key: "monthly_v1", status: "canceled",
|
||||||
|
stripe_customer_id: "cus_canceled"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_not_includes Account::Subscription.paid, subscription
|
||||||
|
end
|
||||||
|
|
||||||
|
test "paid scope excludes comped accounts" do
|
||||||
|
subscription = Account::Subscription.create!(
|
||||||
|
account: accounts(:initech), plan_key: "monthly_v1", status: "active",
|
||||||
|
stripe_customer_id: "cus_comped"
|
||||||
|
)
|
||||||
|
Account::BillingWaiver.create!(account: accounts(:initech))
|
||||||
|
|
||||||
|
assert_not_includes Account::Subscription.paid, subscription
|
||||||
|
end
|
||||||
|
|
||||||
test "pause pauses Stripe subscription with void behavior" do
|
test "pause pauses Stripe subscription with void behavior" do
|
||||||
subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
|
subscription = Account::Subscription.new(stripe_subscription_id: "sub_123")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user