diff --git a/backend/app/services/onetime/backfill_dividend_investment_amounts.rb b/backend/app/services/onetime/backfill_dividend_investment_amounts.rb new file mode 100644 index 0000000000..1f616e1806 --- /dev/null +++ b/backend/app/services/onetime/backfill_dividend_investment_amounts.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +# Onetime script to backfill investment_amount_cents on dividends +# +# Dividend rounds 9 and 12 are skipped +# because their investors were created via CSV import without corresponding share_holdings +# or convertible_securities records, the original CSV is needed for those. +# +# Usage: +# Onetime::BackfillDividendInvestmentAmounts.perform(dry_run: true) # Preview only +# Onetime::BackfillDividendInvestmentAmounts.perform(dry_run: false) # Actually update +class Onetime::BackfillDividendInvestmentAmounts + SKIPPED_DIVIDEND_ROUND_IDS = [9, 12].freeze + + def self.perform(dry_run: true) + new(dry_run:).perform + end + + def initialize(dry_run: true) + @dry_run = dry_run + @updated_count = 0 + @skipped_count = 0 + @already_set_count = 0 + @zero_investment_entries = [] + end + + def perform + puts dry_run ? "DRY RUN MODE - No changes will be made" : "LIVE MODE - Dividends will be updated" + puts "" + + dividend_rounds = DividendRound.where.not(id: SKIPPED_DIVIDEND_ROUND_IDS).order(:id) + puts "Processing #{dividend_rounds.count} dividend rounds (skipping IDs: #{SKIPPED_DIVIDEND_ROUND_IDS.join(', ')})..." + puts "================================================================================" + + dividend_rounds.find_each do |dividend_round| + process_dividend_round(dividend_round) + end + + print_summary + end + + private + attr_reader :dry_run + + def process_dividend_round(dividend_round) + puts "\n--- Dividend Round ##{dividend_round.id} (#{dividend_round.company.name}) ---" + puts " Issued at: #{dividend_round.issued_at}" + + dividend_round.dividends.includes(company_investor: [:share_holdings, :convertible_securities]).find_each do |dividend| + process_dividend(dividend, dividend_round) + end + end + + def process_dividend(dividend, dividend_round) + if dividend.investment_amount_cents.present? && dividend.investment_amount_cents > 0 + @already_set_count += 1 + return + end + + company_investor = dividend.company_investor + investment_amount = calculate_investment_amount(dividend, company_investor, dividend_round.issued_at) + + if investment_amount == 0 + @zero_investment_entries << { dividend_id: dividend.id, email: company_investor.user.email, dividend_round_id: dividend_round.id } + end + + if dry_run + puts " [DRY RUN] Dividend ##{dividend.id} (#{company_investor.user.email}): would set investment_amount_cents = #{investment_amount}" + else + dividend.update_column(:investment_amount_cents, investment_amount) + puts " [UPDATED] Dividend ##{dividend.id} (#{company_investor.user.email}): investment_amount_cents = #{investment_amount}" + end + @updated_count += 1 + end + + def calculate_investment_amount(dividend, company_investor, issued_at) + if dividend.number_of_shares.present? + company_investor.share_holdings + .where("issued_at <= ?", issued_at) + .sum(:total_amount_in_cents) + else + company_investor.convertible_securities + .where("issued_at <= ?", issued_at) + .sum(:principal_value_in_cents) + end + end + + def print_summary + puts "\n================================================================================" + puts "SUMMARY:" + puts "================================================================================" + puts " Updated: #{@updated_count}" + puts " Already set: #{@already_set_count}" + puts " Zero investment: #{@zero_investment_entries.size}" + puts " Skipped dividend rounds: #{SKIPPED_DIVIDEND_ROUND_IDS.join(', ')}" + + if @zero_investment_entries.any? + puts "\n Zero investment dividends:" + @zero_investment_entries.each do |entry| + puts " - Dividend ##{entry[:dividend_id]} (DR ##{entry[:dividend_round_id]}, #{entry[:email]})" + end + end + + if dry_run + puts "\n✓ Dry run complete! No changes were made." + puts " To actually update, run with dry_run: false" + else + puts "\n✓ Backfill complete!" + end + end +end diff --git a/backend/spec/services/onetime/backfill_dividend_investment_amounts_spec.rb b/backend/spec/services/onetime/backfill_dividend_investment_amounts_spec.rb new file mode 100644 index 0000000000..189b233c2d --- /dev/null +++ b/backend/spec/services/onetime/backfill_dividend_investment_amounts_spec.rb @@ -0,0 +1,433 @@ +# frozen_string_literal: true + +RSpec.describe Onetime::BackfillDividendInvestmentAmounts do + let(:company) { create(:company) } + + describe ".perform" do + context "with share holders only (e.g. Pylon)" do + let(:company_investor) { create(:company_investor, company:) } + let(:dividend_round) { create(:dividend_round, company:, issued_at: 1.day.ago) } + let!(:share_holding) do + create(:share_holding, + company_investor:, + issued_at: 1.month.ago, + number_of_shares: 100, + total_amount_in_cents: 50_000_00) + end + let!(:dividend) do + create(:dividend, + company:, + company_investor:, + dividend_round:, + investment_amount_cents: nil, + total_amount_in_cents: 1_000_00) + end + + it "sets investment_amount_cents from share_holdings" do + described_class.perform(dry_run: false) + expect(dividend.reload.investment_amount_cents).to eq(50_000_00) + end + end + + context "with convertible holders only (e.g. Drink LMNT, Austin Flipsters, Fierce)" do + let(:company_investor) { create(:company_investor, company:) } + let(:dividend_round) { create(:dividend_round, company:, issued_at: 1.day.ago) } + let!(:convertible_security) do + create(:convertible_security, + company_investor:, + issued_at: 1.month.ago, + principal_value_in_cents: 25_000_00) + end + let!(:dividend) do + create(:dividend, + company:, + company_investor:, + dividend_round:, + number_of_shares: nil, + investment_amount_cents: nil, + total_amount_in_cents: 500_00) + end + + it "sets investment_amount_cents from convertible_securities" do + described_class.perform(dry_run: false) + expect(dividend.reload.investment_amount_cents).to eq(25_000_00) + end + end + + context "with both share holders and convertible holders (e.g. Gumroad)" do + let(:share_investor) { create(:company_investor, company:) } + let(:convertible_investor) { create(:company_investor, company:) } + let(:both_investor) { create(:company_investor, company:) } + let(:dividend_round) { create(:dividend_round, company:, issued_at: 1.day.ago) } + + let!(:share_holding) do + create(:share_holding, + company_investor: share_investor, + issued_at: 1.month.ago, + number_of_shares: 200, + total_amount_in_cents: 100_000_00) + end + let!(:convertible_security) do + create(:convertible_security, + company_investor: convertible_investor, + issued_at: 1.month.ago, + principal_value_in_cents: 10_000_00) + end + let!(:both_share_holding) do + create(:share_holding, + company_investor: both_investor, + issued_at: 1.month.ago, + number_of_shares: 50, + total_amount_in_cents: 25_000_00) + end + let!(:both_convertible_security) do + create(:convertible_security, + company_investor: both_investor, + issued_at: 1.month.ago, + principal_value_in_cents: 5_000_00) + end + + let!(:share_dividend) do + create(:dividend, + company:, + company_investor: share_investor, + dividend_round:, + number_of_shares: 200, + investment_amount_cents: nil, + total_amount_in_cents: 2_000_00) + end + let!(:convertible_dividend) do + create(:dividend, + company:, + company_investor: convertible_investor, + dividend_round:, + number_of_shares: nil, + investment_amount_cents: nil, + total_amount_in_cents: 200_00) + end + # Investor with both shares and convertibles gets TWO dividends per round + let!(:both_shares_dividend) do + create(:dividend, + company:, + company_investor: both_investor, + dividend_round:, + number_of_shares: 50, + investment_amount_cents: nil, + total_amount_in_cents: 300_00) + end + let!(:both_convertible_dividend) do + create(:dividend, + company:, + company_investor: both_investor, + dividend_round:, + number_of_shares: nil, + investment_amount_cents: nil, + total_amount_in_cents: 200_00) + end + + it "sets investment_amount_cents correctly for each type" do + described_class.perform(dry_run: false) + + expect(share_dividend.reload.investment_amount_cents).to eq(100_000_00) + expect(convertible_dividend.reload.investment_amount_cents).to eq(10_000_00) + expect(both_shares_dividend.reload.investment_amount_cents).to eq(25_000_00) + expect(both_convertible_dividend.reload.investment_amount_cents).to eq(5_000_00) + end + end + + context "with multiple share holdings and convertible securities per investor" do + let(:company_investor) { create(:company_investor, company:) } + let(:dividend_round) { create(:dividend_round, company:, issued_at: 1.day.ago) } + + let!(:share_holding_1) do + create(:share_holding, + company_investor:, + issued_at: 6.months.ago, + number_of_shares: 100, + total_amount_in_cents: 10_000_00) + end + let!(:share_holding_2) do + create(:share_holding, + company_investor:, + issued_at: 3.months.ago, + number_of_shares: 50, + total_amount_in_cents: 7_500_00) + end + let!(:convertible_security_1) do + create(:convertible_security, + company_investor:, + issued_at: 6.months.ago, + principal_value_in_cents: 5_000_00) + end + let!(:convertible_security_2) do + create(:convertible_security, + company_investor:, + issued_at: 3.months.ago, + principal_value_in_cents: 3_000_00) + end + + let!(:shares_dividend) do + create(:dividend, + company:, + company_investor:, + dividend_round:, + number_of_shares: 150, + investment_amount_cents: nil, + total_amount_in_cents: 1_000_00) + end + let!(:convertible_dividend) do + create(:dividend, + company:, + company_investor:, + dividend_round:, + number_of_shares: nil, + investment_amount_cents: nil, + total_amount_in_cents: 500_00) + end + + it "sums all share holdings for shares dividend and all convertibles for convertible dividend" do + described_class.perform(dry_run: false) + expect(shares_dividend.reload.investment_amount_cents).to eq(17_500_00) + expect(convertible_dividend.reload.investment_amount_cents).to eq(8_000_00) + end + end + + context "only includes holdings issued before the dividend round" do + let(:company_investor) { create(:company_investor, company:) } + let(:dividend_round) { create(:dividend_round, company:, issued_at: 2.months.ago) } + + let!(:old_share_holding) do + create(:share_holding, + company_investor:, + issued_at: 6.months.ago, + number_of_shares: 100, + total_amount_in_cents: 10_000_00) + end + let!(:new_share_holding) do + create(:share_holding, + company_investor:, + issued_at: 1.month.ago, + number_of_shares: 200, + total_amount_in_cents: 20_000_00) + end + let!(:old_convertible) do + create(:convertible_security, + company_investor:, + issued_at: 6.months.ago, + principal_value_in_cents: 5_000_00) + end + let!(:new_convertible) do + create(:convertible_security, + company_investor:, + issued_at: 1.month.ago, + principal_value_in_cents: 8_000_00) + end + + let!(:shares_dividend) do + create(:dividend, + company:, + company_investor:, + dividend_round:, + number_of_shares: 100, + investment_amount_cents: nil, + total_amount_in_cents: 500_00) + end + let!(:convertible_dividend) do + create(:dividend, + company:, + company_investor:, + dividend_round:, + number_of_shares: nil, + investment_amount_cents: nil, + total_amount_in_cents: 300_00) + end + + it "only includes holdings issued before the dividend round" do + described_class.perform(dry_run: false) + expect(shares_dividend.reload.investment_amount_cents).to eq(10_000_00) + expect(convertible_dividend.reload.investment_amount_cents).to eq(5_000_00) + end + end + + context "skips dividend rounds 9 and 12" do + let(:dividend_round_9) { create(:dividend_round, id: 9, company:, issued_at: 1.day.ago) } + let(:dividend_round_12) { create(:dividend_round, id: 12, company:, issued_at: 1.day.ago) } + let(:company_investor) { create(:company_investor, company:) } + + let!(:convertible_security) do + create(:convertible_security, + company_investor:, + issued_at: 1.month.ago, + principal_value_in_cents: 10_000_00) + end + + let!(:dividend_in_round_9) do + create(:dividend, + company:, + company_investor:, + dividend_round: dividend_round_9, + investment_amount_cents: nil, + total_amount_in_cents: 100_00) + end + let!(:dividend_in_round_12) do + create(:dividend, + company:, + company_investor:, + dividend_round: dividend_round_12, + investment_amount_cents: nil, + total_amount_in_cents: 200_00) + end + + it "does not update dividends in skipped rounds" do + described_class.perform(dry_run: false) + expect(dividend_in_round_9.reload.investment_amount_cents).to be_nil + expect(dividend_in_round_12.reload.investment_amount_cents).to be_nil + end + end + + context "skips dividends that already have investment_amount_cents" do + let(:company_investor) { create(:company_investor, company:) } + let(:dividend_round) { create(:dividend_round, company:, issued_at: 1.day.ago) } + + let!(:share_holding) do + create(:share_holding, + company_investor:, + issued_at: 1.month.ago, + number_of_shares: 100, + total_amount_in_cents: 50_000_00) + end + + let!(:dividend) do + create(:dividend, + company:, + company_investor:, + dividend_round:, + investment_amount_cents: 99_999_00, + total_amount_in_cents: 1_000_00) + end + + it "does not overwrite existing investment_amount_cents" do + described_class.perform(dry_run: false) + expect(dividend.reload.investment_amount_cents).to eq(99_999_00) + end + end + + context "when calculated investment is zero" do + let(:company_investor) { create(:company_investor, company:) } + let(:dividend_round) { create(:dividend_round, company:, issued_at: 1.day.ago) } + + let!(:dividend) do + create(:dividend, + company:, + company_investor:, + dividend_round:, + investment_amount_cents: nil, + total_amount_in_cents: 100_00) + end + + it "still updates investment_amount_cents to zero with a warning" do + described_class.perform(dry_run: false) + expect(dividend.reload.investment_amount_cents).to eq(0) + end + end + + context "handles investment_amount_cents set to 0 (local DB NOT NULL constraint)" do + let(:company_investor) { create(:company_investor, company:) } + let(:dividend_round) { create(:dividend_round, company:, issued_at: 1.day.ago) } + + let!(:share_holding) do + create(:share_holding, + company_investor:, + issued_at: 1.month.ago, + number_of_shares: 100, + total_amount_in_cents: 50_000_00) + end + + let!(:dividend) do + create(:dividend, + company:, + company_investor:, + dividend_round:, + investment_amount_cents: 0, + total_amount_in_cents: 1_000_00) + end + + it "backfills dividends with investment_amount_cents of 0" do + described_class.perform(dry_run: false) + expect(dividend.reload.investment_amount_cents).to eq(50_000_00) + end + end + + context "dry run mode" do + let(:company_investor) { create(:company_investor, company:) } + let(:dividend_round) { create(:dividend_round, company:, issued_at: 1.day.ago) } + + let!(:share_holding) do + create(:share_holding, + company_investor:, + issued_at: 1.month.ago, + number_of_shares: 100, + total_amount_in_cents: 50_000_00) + end + + let!(:dividend) do + create(:dividend, + company:, + company_investor:, + dividend_round:, + investment_amount_cents: nil, + total_amount_in_cents: 1_000_00) + end + + it "does not update any records" do + described_class.perform(dry_run: true) + expect(dividend.reload.investment_amount_cents).to be_nil + end + end + + context "with multiple dividend rounds for the same company" do + let(:company_investor) { create(:company_investor, company:) } + let(:round_1) { create(:dividend_round, company:, issued_at: 6.months.ago) } + let(:round_2) { create(:dividend_round, company:, issued_at: 1.day.ago) } + + let!(:old_share_holding) do + create(:share_holding, + company_investor:, + issued_at: 1.year.ago, + number_of_shares: 100, + total_amount_in_cents: 10_000_00) + end + let!(:new_share_holding) do + create(:share_holding, + company_investor:, + issued_at: 3.months.ago, + number_of_shares: 200, + total_amount_in_cents: 30_000_00) + end + + let!(:dividend_round_1) do + create(:dividend, + company:, + company_investor:, + dividend_round: round_1, + investment_amount_cents: nil, + total_amount_in_cents: 500_00) + end + let!(:dividend_round_2) do + create(:dividend, + company:, + company_investor:, + dividend_round: round_2, + investment_amount_cents: nil, + total_amount_in_cents: 1_000_00) + end + + it "calculates correct historical investment amount for each round" do + described_class.perform(dry_run: false) + + expect(dividend_round_1.reload.investment_amount_cents).to eq(10_000_00) + expect(dividend_round_2.reload.investment_amount_cents).to eq(40_000_00) + end + end + end +end