From 65d5035f1738b41b1e6e05a81abce62a365e1251 Mon Sep 17 00:00:00 2001 From: nks2109 Date: Wed, 18 Mar 2026 12:55:40 -0400 Subject: [PATCH 1/2] add active SEP report task --- lib/tasks/hbx_reports/active_sep_report.rake | 175 ++++++++++ .../hbx_reports/active_sep_report_spec.rb | 321 ++++++++++++++++++ 2 files changed, 496 insertions(+) create mode 100644 lib/tasks/hbx_reports/active_sep_report.rake create mode 100644 spec/lib/tasks/hbx_reports/active_sep_report_spec.rb diff --git a/lib/tasks/hbx_reports/active_sep_report.rake b/lib/tasks/hbx_reports/active_sep_report.rake new file mode 100644 index 00000000000..deab3faa8ae --- /dev/null +++ b/lib/tasks/hbx_reports/active_sep_report.rake @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require 'csv' +# RAILS_ENV=production bundle exec rake reports:active_sep_report start_date="2026-01-01" end_date="2026-03-18" +namespace :reports do + desc "Person Details report for people with active IVL SEPs" + task :active_sep_report => :environment do + + start_date_str = ENV['start_date'].to_s.strip + end_date_str = ENV['end_date'].to_s.strip + start_date = Date.strptime(start_date_str, '%Y-%m-%d') if start_date_str.present? + end_date = Date.strptime(end_date_str, '%Y-%m-%d') if end_date_str.present? + + if start_date.blank? || end_date.blank? + puts "Please provide start_date and end_date in YYYY-MM-DD format." + puts 'Example: bundle exec rake reports:active_sep_report start_date="2026-01-01" end_date="2026-03-18"' + exit + end + + file_name = "#{Rails.root}/active_sep_report_#{start_date.strftime('%Y%m%d')}_#{end_date.strftime('%Y%m%d')}.csv" + puts "Generating Active SEP Report: #{file_name}" unless Rails.env.test? + + ivl_qle_ids = QualifyingLifeEventKind.individual_market_events.map(&:id) + + QualifyingLifeEventKind.individual_market_non_self_attested_events.map(&:id) + + families = Family.where( + :special_enrollment_periods => { + '$elemMatch' => { + :start_on.lte => end_date, + :end_on.gte => start_date, + :qualifying_life_event_kind_id.in => ivl_qle_ids + } + } + ) + + total_count = families.count + count = 0 + batch_size = 500 + offset = 0 + + CSV.open(file_name, "w") do |csv| + csv << [ + "Primary HBX ID", + "Consumer HBX ID", + "Consumer Name", + "Email Address", + "AI/AN Indicator", + "Tribe State", + "Tribe Name", + "Phone Number", + "Home Address", + "Mailing Address", + "Contact Method", + "Household Size", + "Name of SEP Used", + "SEP Start Date", + "SEP End Date", + "Plan Type", + "Carrier", + "Plan Title", + "Net Annual Income", + "Is Broker Assisted", + "Broker Name" + ] + + while offset <= total_count + families.no_timeout.offset(offset).limit(batch_size).each do |family| + + person = family.primary_person + next unless person.present? + + active_seps = family.special_enrollment_periods.select do |sep| + sep.start_on.present? && + sep.end_on.present? && + sep.start_on <= end_date && + sep.end_on >= start_date && + ivl_qle_ids.include?(sep.qualifying_life_event_kind_id) + end + + next if active_seps.blank? + + primary_hbx_id = person.hbx_id + household_size = family.active_family_members.count + + # Enrollment info (latest active IVL enrollment for the family) + latest_enrollment = family.hbx_enrollments + .individual_market + .where(:aasm_state.in => HbxEnrollment::ENROLLED_AND_RENEWAL_STATUSES) + .order_by(created_at: :desc) + .first + + plan_type = latest_enrollment&.product&.kind&.to_s + carrier = latest_enrollment&.product&.issuer_profile&.legal_name + plan_title = latest_enrollment&.product&.title + + # Net annual income from latest determined FA application + net_annual_income = nil + if ::EnrollRegistry.feature_enabled?(:financial_assistance) + latest_app = ::FinancialAssistance::Application.where( + family_id: family.id, + aasm_state: 'determined' + ).order_by(submitted_at: :desc).first + + if latest_app.present? + primary_applicant = latest_app.primary_applicant + net_annual_income = primary_applicant&.net_annual_income&.to_f + end + end + + # Broker info + broker_account = family.active_broker_agency_account + is_broker_assisted = broker_account.present? ? "Yes" : "No" + broker_name = broker_account&.writing_agent&.person&.full_name + + family.active_family_members.each do |family_member| + consumer = family_member.person + next unless consumer.present? + + # Consumer level details + consumer_hbx_id = consumer.hbx_id + consumer_name = consumer.full_name + email = consumer.home_email&.address || consumer.emails&.first&.address + ai_an = consumer.indian_tribe_member ? "Yes" : "No" + tribe_state = consumer.tribal_state + tribe_name = consumer.tribal_name + phone = consumer.work_phone_or_best + + home_addr = consumer.home_address + home_address_str = ([home_addr.address_1, home_addr.address_2, home_addr.city, home_addr.state, home_addr.zip].compact.reject(&:blank?).join(', ') if home_addr) + + mail_addr = consumer.has_mailing_address? ? consumer.addresses.detect { |a| a.kind == "mailing" } : nil + mailing_address_str = ([mail_addr.address_1, mail_addr.address_2, mail_addr.city, mail_addr.state, mail_addr.zip].compact.reject(&:blank?).join(', ') if mail_addr) + + contact_method = consumer.consumer_role&.contact_method + + active_seps.each do |sep| + csv << [ + primary_hbx_id, + consumer_hbx_id, + consumer_name, + email, + ai_an, + tribe_state, + tribe_name, + phone, + home_address_str, + mailing_address_str, + contact_method, + household_size, + sep.title, + sep.start_on&.strftime("%m/%d/%Y"), + sep.end_on&.strftime("%m/%d/%Y"), + plan_type, + carrier, + plan_title, + net_annual_income, + is_broker_assisted, + broker_name + ] + end + end + + count += 1 + puts "#{count}/#{total_count} families processed at #{Time.now}" if count % 1000 == 0 + rescue StandardError => e + puts "Error processing family #{family.hbx_id}: #{e.message}" + + end + offset += batch_size + end + end + + puts "***** Successfully generated report: #{file_name} ******" unless Rails.env.test? + end +end diff --git a/spec/lib/tasks/hbx_reports/active_sep_report_spec.rb b/spec/lib/tasks/hbx_reports/active_sep_report_spec.rb new file mode 100644 index 00000000000..c4fbe6c1ba4 --- /dev/null +++ b/spec/lib/tasks/hbx_reports/active_sep_report_spec.rb @@ -0,0 +1,321 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'rake' +require 'csv' + +describe 'Active SEP Report', :dbclean => :after_each do + context 'reports:active_sep_report' do + + let(:start_date) { "2026-01-01" } + let(:end_date) { "2026-03-18" } + let(:file_name) { "#{Rails.root}/active_sep_report_#{start_date.delete('-')}_#{end_date.delete('-')}.csv" } + + let(:person) do + FactoryBot.create( + :person, + :with_consumer_role, + first_name: "John", + last_name: "Doe", + tribal_id: "123456789", + tribal_state: "ME", + tribal_name: "Penobscot" + ) + end + + let(:family) { FactoryBot.create(:family, :with_primary_family_member, person: person) } + + let!(:qle_kind) do + FactoryBot.create( + :qualifying_life_event_kind, + title: "Lost other health insurance", + reason: "lost_access_to_mec", + market_kind: "individual" + ) + end + + let!(:sep) do + family.special_enrollment_periods.create!( + qualifying_life_event_kind_id: qle_kind.id, + qle_on: Date.new(2026, 1, 15), + start_on: Date.new(2026, 1, 15), + end_on: Date.new(2026, 3, 15), + effective_on: Date.new(2026, 2, 1), + effective_on_kind: "first_of_next_month", + submitted_at: DateTime.new(2026, 1, 15), + title: "Lost other health insurance" + ) + end + + before do + allow(EnrollRegistry).to receive(:feature_enabled?).and_call_original + allow(EnrollRegistry).to receive(:feature_enabled?).with(:financial_assistance).and_return(false) + end + + after(:each) do + File.delete(file_name) if File.exist?(file_name) + end + + context 'when invoked with valid date range' do + before do + ClimateControl.modify start_date: start_date, end_date: end_date do + load File.expand_path("#{Rails.root}/lib/tasks/hbx_reports/active_sep_report.rake", __FILE__) + Rake::Task.define_task(:environment) + Rake::Task["reports:active_sep_report"].reenable + Rake::Task["reports:active_sep_report"].invoke + end + end + + it "generates the CSV file" do + expect(File.exist?(file_name)).to eq(true) + end + + it "includes the correct headers" do + csv = CSV.read(file_name) + expect(csv.first).to eq([ + "Primary HBX ID", + "Consumer HBX ID", + "Consumer Name", + "Email Address", + "AI/AN Indicator", + "Tribe State", + "Tribe Name", + "Phone Number", + "Home Address", + "Mailing Address", + "Contact Method", + "Household Size", + "Name of SEP Used", + "SEP Start Date", + "SEP End Date", + "Plan Type", + "Carrier", + "Plan Title", + "Net Annual Income", + "Is Broker Assisted", + "Broker Name" + ]) + end + + it "includes the family data row" do + csv = CSV.read(file_name) + expect(csv.length).to eq(2) # header + 1 data row + data_row = csv.last + expect(data_row[0]).to eq(person.hbx_id) # Primary HBX ID + expect(data_row[1]).to eq(person.hbx_id) # Consumer HBX ID + expect(data_row[2]).to eq(person.full_name) + expect(data_row[4]).to eq("Yes") # AI/AN + expect(data_row[5]).to eq("ME") # Tribe State + expect(data_row[6]).to eq("Penobscot") # Tribe Name + expect(data_row[12]).to eq("Lost other health insurance") # SEP title + expect(data_row[13]).to eq("01/15/2026") # SEP Start + expect(data_row[14]).to eq("03/15/2026") # SEP End + expect(data_row[19]).to eq("No") # Is Broker Assisted + end + end + + context 'with multiple active SEPs' do + let!(:sep2) do + family.special_enrollment_periods.create!( + qualifying_life_event_kind_id: qle_kind.id, + qle_on: Date.new(2026, 2, 10), + start_on: Date.new(2026, 2, 10), + end_on: Date.new(2026, 4, 10), + effective_on: Date.new(2026, 3, 1), + effective_on_kind: "first_of_next_month", + submitted_at: DateTime.new(2026, 2, 10), + title: "A]nother qualifying event" + ) + end + + before do + ClimateControl.modify start_date: start_date, end_date: end_date do + load File.expand_path("#{Rails.root}/lib/tasks/hbx_reports/active_sep_report.rake", __FILE__) + Rake::Task.define_task(:environment) + Rake::Task["reports:active_sep_report"].reenable + Rake::Task["reports:active_sep_report"].invoke + end + end + + it "generates one row per active SEP" do + csv = CSV.read(file_name) + expect(csv.length).to eq(3) # header + 2 data rows + end + end + + context 'when SEP is outside the date range' do + let!(:old_sep) do + family.special_enrollment_periods.create!( + qualifying_life_event_kind_id: qle_kind.id, + qle_on: Date.new(2025, 6, 1), + start_on: Date.new(2025, 6, 1), + end_on: Date.new(2025, 7, 1), + effective_on: Date.new(2025, 7, 1), + effective_on_kind: "first_of_next_month", + submitted_at: DateTime.new(2025, 6, 1), + title: "Expired SEP" + ) + end + + before do + # Remove the in-range SEP so only the old one exists + sep.destroy + end + + it "does not include expired SEP families" do + ClimateControl.modify start_date: start_date, end_date: end_date do + load File.expand_path("#{Rails.root}/lib/tasks/hbx_reports/active_sep_report.rake", __FILE__) + Rake::Task.define_task(:environment) + Rake::Task["reports:active_sep_report"].reenable + Rake::Task["reports:active_sep_report"].invoke + end + + csv = CSV.read(file_name) + expect(csv.length).to eq(1) # header only + end + end + + context 'with enrollment data' do + let(:product) do + FactoryBot.create( + :benefit_markets_products_health_products_health_product, + :with_issuer_profile, + :ivl_product, + title: "BlueChoice Silver", + health_plan_kind: :hmo + ) + end + + let!(:enrollment) do + FactoryBot.create( + :hbx_enrollment, + family: family, + kind: "individual", + aasm_state: "coverage_selected", + product: product, + effective_on: Date.new(2026, 2, 1) + ) + end + + before do + ClimateControl.modify start_date: start_date, end_date: end_date do + load File.expand_path("#{Rails.root}/lib/tasks/hbx_reports/active_sep_report.rake", __FILE__) + Rake::Task.define_task(:environment) + Rake::Task["reports:active_sep_report"].reenable + Rake::Task["reports:active_sep_report"].invoke + end + end + + it "includes enrollment plan details" do + csv = CSV.read(file_name) + data_row = csv.last + expect(data_row[15]).to eq("health") # Plan Type + expect(data_row[16]).to be_present # Carrier + expect(data_row[17]).to eq("BlueChoice Silver") # Plan Title + end + end + + context 'with financial assistance application' do + before do + allow(EnrollRegistry).to receive(:feature_enabled?).with(:financial_assistance).and_return(true) + + fa_app = double("FinancialAssistance::Application") + primary_applicant = double("FinancialAssistance::Applicant", net_annual_income: 45_000.0) + allow(fa_app).to receive(:present?).and_return(true) + allow(fa_app).to receive(:primary_applicant).and_return(primary_applicant) + + fa_scope = double("fa_scope") + allow(fa_scope).to receive(:order_by).with(submitted_at: :desc).and_return(fa_scope) + allow(fa_scope).to receive(:first).and_return(fa_app) + + allow(::FinancialAssistance::Application).to receive(:where).with( + family_id: family.id, + aasm_state: 'determined' + ).and_return(fa_scope) + + ClimateControl.modify start_date: start_date, end_date: end_date do + load File.expand_path("#{Rails.root}/lib/tasks/hbx_reports/active_sep_report.rake", __FILE__) + Rake::Task.define_task(:environment) + Rake::Task["reports:active_sep_report"].reenable + Rake::Task["reports:active_sep_report"].invoke + end + end + + it "includes net annual income" do + csv = CSV.read(file_name) + data_row = csv.last + expect(data_row[18]).to eq("45000.0") + end + end + + context 'with broker agency account' do + let(:broker_person) { FactoryBot.create(:person, first_name: "Jane", last_name: "Broker") } + let(:broker_role) { FactoryBot.create(:broker_role, person: broker_person) } + + before do + broker_account = double("BrokerAgencyAccount", + present?: true, + writing_agent: broker_role) + allow_any_instance_of(Family).to receive(:active_broker_agency_account).and_return(broker_account) + + ClimateControl.modify start_date: start_date, end_date: end_date do + load File.expand_path("#{Rails.root}/lib/tasks/hbx_reports/active_sep_report.rake", __FILE__) + Rake::Task.define_task(:environment) + Rake::Task["reports:active_sep_report"].reenable + Rake::Task["reports:active_sep_report"].invoke + end + end + + it "includes broker information" do + csv = CSV.read(file_name) + data_row = csv.last + expect(data_row[19]).to eq("Yes") + expect(data_row[20]).to eq("Jane Broker") + end + end + + context 'when person is not AI/AN' do + let(:non_tribal_person) do + FactoryBot.create( + :person, + :with_consumer_role, + first_name: "Jane", + last_name: "Smith", + tribal_id: nil, + tribal_state: nil, + tribal_name: nil + ) + end + + let(:non_tribal_family) { FactoryBot.create(:family, :with_primary_family_member, person: non_tribal_person) } + + let!(:non_tribal_sep) do + non_tribal_family.special_enrollment_periods.create!( + qualifying_life_event_kind_id: qle_kind.id, + qle_on: Date.new(2026, 1, 20), + start_on: Date.new(2026, 1, 20), + end_on: Date.new(2026, 3, 20), + effective_on: Date.new(2026, 2, 1), + effective_on_kind: "first_of_next_month", + submitted_at: DateTime.new(2026, 1, 20), + title: "Lost other health insurance" + ) + end + + before do + ClimateControl.modify start_date: start_date, end_date: end_date do + load File.expand_path("#{Rails.root}/lib/tasks/hbx_reports/active_sep_report.rake", __FILE__) + Rake::Task.define_task(:environment) + Rake::Task["reports:active_sep_report"].reenable + Rake::Task["reports:active_sep_report"].invoke + end + end + + it "shows AI/AN as No" do + csv = CSV.read(file_name) + non_tribal_row = csv.detect { |row| row[1] == non_tribal_person.hbx_id } + expect(non_tribal_row[4]).to eq("No") + end + end + end +end From 5e53748fd984b6750f1c1890947343d197bca293 Mon Sep 17 00:00:00 2001 From: nks2109 Date: Wed, 18 Mar 2026 14:21:06 -0400 Subject: [PATCH 2/2] add new family_hbx_id column --- lib/tasks/hbx_reports/active_sep_report.rake | 7 +++- .../hbx_reports/active_sep_report_spec.rb | 38 ++++++++++--------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/lib/tasks/hbx_reports/active_sep_report.rake b/lib/tasks/hbx_reports/active_sep_report.rake index deab3faa8ae..660145a2dcf 100644 --- a/lib/tasks/hbx_reports/active_sep_report.rake +++ b/lib/tasks/hbx_reports/active_sep_report.rake @@ -31,7 +31,7 @@ namespace :reports do :qualifying_life_event_kind_id.in => ivl_qle_ids } } - ) + ).order_by(:_id.asc) total_count = families.count count = 0 @@ -40,6 +40,7 @@ namespace :reports do CSV.open(file_name, "w") do |csv| csv << [ + "Family HBX ID", "Primary HBX ID", "Consumer HBX ID", "Consumer Name", @@ -63,7 +64,7 @@ namespace :reports do "Broker Name" ] - while offset <= total_count + while offset < total_count families.no_timeout.offset(offset).limit(batch_size).each do |family| person = family.primary_person @@ -80,6 +81,7 @@ namespace :reports do next if active_seps.blank? primary_hbx_id = person.hbx_id + family_hbx_id = family.hbx_assigned_id household_size = family.active_family_members.count # Enrollment info (latest active IVL enrollment for the family) @@ -135,6 +137,7 @@ namespace :reports do active_seps.each do |sep| csv << [ + family_hbx_id, primary_hbx_id, consumer_hbx_id, consumer_name, diff --git a/spec/lib/tasks/hbx_reports/active_sep_report_spec.rb b/spec/lib/tasks/hbx_reports/active_sep_report_spec.rb index c4fbe6c1ba4..eb856b22c7b 100644 --- a/spec/lib/tasks/hbx_reports/active_sep_report_spec.rb +++ b/spec/lib/tasks/hbx_reports/active_sep_report_spec.rb @@ -73,6 +73,7 @@ it "includes the correct headers" do csv = CSV.read(file_name) expect(csv.first).to eq([ + "Family HBX ID", "Primary HBX ID", "Consumer HBX ID", "Consumer Name", @@ -101,16 +102,17 @@ csv = CSV.read(file_name) expect(csv.length).to eq(2) # header + 1 data row data_row = csv.last - expect(data_row[0]).to eq(person.hbx_id) # Primary HBX ID - expect(data_row[1]).to eq(person.hbx_id) # Consumer HBX ID - expect(data_row[2]).to eq(person.full_name) - expect(data_row[4]).to eq("Yes") # AI/AN - expect(data_row[5]).to eq("ME") # Tribe State - expect(data_row[6]).to eq("Penobscot") # Tribe Name - expect(data_row[12]).to eq("Lost other health insurance") # SEP title - expect(data_row[13]).to eq("01/15/2026") # SEP Start - expect(data_row[14]).to eq("03/15/2026") # SEP End - expect(data_row[19]).to eq("No") # Is Broker Assisted + expect(data_row[0]).to eq(family.hbx_assigned_id.to_s) # Family HBX ID + expect(data_row[1]).to eq(person.hbx_id) # Primary HBX ID + expect(data_row[2]).to eq(person.hbx_id) # Consumer HBX ID + expect(data_row[3]).to eq(person.full_name) + expect(data_row[5]).to eq("Yes") # AI/AN + expect(data_row[6]).to eq("ME") # Tribe State + expect(data_row[7]).to eq("Penobscot") # Tribe Name + expect(data_row[13]).to eq("Lost other health insurance") # SEP title + expect(data_row[14]).to eq("01/15/2026") # SEP Start + expect(data_row[15]).to eq("03/15/2026") # SEP End + expect(data_row[20]).to eq("No") # Is Broker Assisted end end @@ -209,9 +211,9 @@ it "includes enrollment plan details" do csv = CSV.read(file_name) data_row = csv.last - expect(data_row[15]).to eq("health") # Plan Type - expect(data_row[16]).to be_present # Carrier - expect(data_row[17]).to eq("BlueChoice Silver") # Plan Title + expect(data_row[16]).to eq("health") # Plan Type + expect(data_row[17]).to be_present # Carrier + expect(data_row[18]).to eq("BlueChoice Silver") # Plan Title end end @@ -244,7 +246,7 @@ it "includes net annual income" do csv = CSV.read(file_name) data_row = csv.last - expect(data_row[18]).to eq("45000.0") + expect(data_row[19]).to eq("45000.0") end end @@ -269,8 +271,8 @@ it "includes broker information" do csv = CSV.read(file_name) data_row = csv.last - expect(data_row[19]).to eq("Yes") - expect(data_row[20]).to eq("Jane Broker") + expect(data_row[20]).to eq("Yes") + expect(data_row[21]).to eq("Jane Broker") end end @@ -313,8 +315,8 @@ it "shows AI/AN as No" do csv = CSV.read(file_name) - non_tribal_row = csv.detect { |row| row[1] == non_tribal_person.hbx_id } - expect(non_tribal_row[4]).to eq("No") + non_tribal_row = csv.detect { |row| row[2] == non_tribal_person.hbx_id } + expect(non_tribal_row[5]).to eq("No") end end end