diff --git a/backend/app/models/dividend.rb b/backend/app/models/dividend.rb index 756a4972f6..6862833bdc 100644 --- a/backend/app/models/dividend.rb +++ b/backend/app/models/dividend.rb @@ -29,6 +29,7 @@ class Dividend < ApplicationRecord validates :withholding_percentage, numericality: { greater_than_or_equal_to: 0, only_integer: true }, allow_nil: true validates :net_amount_in_cents, numericality: { greater_than_or_equal_to: 0, only_integer: true }, allow_nil: true validates :qualified_amount_cents, numericality: { greater_than_or_equal_to: 0, only_integer: true } + validates :investment_amount_cents, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } scope :pending_signup, -> { where(status: PENDING_SIGNUP) } scope :paid, -> { where(status: PAID) } diff --git a/backend/app/services/create_investors_and_dividends.rb b/backend/app/services/create_investors_and_dividends.rb index 17b570916f..8e99a54ec7 100644 --- a/backend/app/services/create_investors_and_dividends.rb +++ b/backend/app/services/create_investors_and_dividends.rb @@ -153,6 +153,7 @@ def create_investments_and_dividends status: user.current_sign_in_at.nil? ? Dividend::PENDING_SIGNUP : Dividend::ISSUED, total_amount_in_cents: dividend_cents, qualified_amount_cents: dividend_cents, + investment_amount_cents: company_investor.investment_amount_in_cents, ) end rescue => e 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..2034fc2b85 --- /dev/null +++ b/backend/app/services/onetime/backfill_dividend_investment_amounts.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class Onetime::BackfillDividendInvestmentAmounts + def perform + backfill_share_based_dividends + backfill_convertible_based_dividends + backfill_remaining_dividends + end + + private + # For share-based dividends (number_of_shares IS NOT NULL), + # use the company_investor's investment_amount_in_cents + def backfill_share_based_dividends + Dividend.where(investment_amount_cents: nil) + .where.not(number_of_shares: nil) + .find_each do |dividend| + dividend.update_columns( + investment_amount_cents: dividend.company_investor.investment_amount_in_cents + ) + end + end + + # For convertible/SAFE-based dividends (number_of_shares IS NULL), + # use the sum of convertible_securities.principal_value_in_cents for that investor + def backfill_convertible_based_dividends + Dividend.where(investment_amount_cents: nil, number_of_shares: nil) + .find_each do |dividend| + principal = dividend.company_investor + .convertible_securities + .sum(:principal_value_in_cents) + dividend.update_columns(investment_amount_cents: principal) + end + end + + # Fallback: set any remaining NULL values to 0 + def backfill_remaining_dividends + Dividend.where(investment_amount_cents: nil) + .update_all(investment_amount_cents: 0) + end +end diff --git a/backend/config/data/seed_templates/gumroad.json b/backend/config/data/seed_templates/gumroad.json index fbdb50990d..e5dd0d7edf 100644 --- a/backend/config/data/seed_templates/gumroad.json +++ b/backend/config/data/seed_templates/gumroad.json @@ -243,6 +243,7 @@ "withheld_tax_cents": 0, "qualified_amount_cents": 0, "number_of_shares": 1230, + "investment_amount_cents": 317340, "status": "Paid" }, "convertible_securities": [ diff --git a/backend/db/migrate/20260209000001_make_dividends_investment_amount_cents_not_null.rb b/backend/db/migrate/20260209000001_make_dividends_investment_amount_cents_not_null.rb new file mode 100644 index 0000000000..ce4c4f04c1 --- /dev/null +++ b/backend/db/migrate/20260209000001_make_dividends_investment_amount_cents_not_null.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class MakeDividendsInvestmentAmountCentsNotNull < ActiveRecord::Migration[7.0] + def up + # Ensure no NULL values remain before adding the constraint. + # The Onetime::BackfillDividendInvestmentAmounts script should be run first, + # but this acts as a safety net. + Dividend.where(investment_amount_cents: nil).update_all(investment_amount_cents: 0) + + change_column_null :dividends, :investment_amount_cents, false + end + + def down + change_column_null :dividends, :investment_amount_cents, true + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index ebdc0f3193..de9b321246 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_01_28_070657) do +ActiveRecord::Schema[8.0].define(version: 2026_02_09_000001) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "pg_catalog.plpgsql" @@ -377,7 +377,7 @@ t.bigint "user_compliance_info_id" t.bigint "qualified_amount_cents", null: false t.datetime "signed_release_at" - t.bigint "investment_amount_cents" + t.bigint "investment_amount_cents", null: false t.string "external_id", null: false t.index ["company_id"], name: "index_dividends_on_company_id" t.index ["company_investor_id"], name: "index_dividends_on_company_investor_id" 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..8e10db16ff --- /dev/null +++ b/backend/spec/services/onetime/backfill_dividend_investment_amounts_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +RSpec.describe Onetime::BackfillDividendInvestmentAmounts do + describe "#perform" do + subject(:service) { described_class.new } + + let(:company) { create(:company) } + let(:dividend_round) { create(:dividend_round, company:) } + + # The column is NOT NULL in schema.rb (post-migration state), but the backfill + # script runs before the migration. Temporarily drop the constraint to simulate. + before do + ActiveRecord::Base.connection.execute( + "ALTER TABLE dividends ALTER COLUMN investment_amount_cents DROP NOT NULL" + ) + end + + after do + ActiveRecord::Base.connection.execute( + "UPDATE dividends SET investment_amount_cents = 0 WHERE investment_amount_cents IS NULL" + ) + ActiveRecord::Base.connection.execute( + "ALTER TABLE dividends ALTER COLUMN investment_amount_cents SET NOT NULL" + ) + end + + context "when dividends are share-based" do + let(:company_investor) { create(:company_investor, company:, investment_amount_in_cents: 500_00) } + + let!(:dividend) do + create(:dividend, company:, company_investor:, dividend_round:, number_of_shares: 100).tap do |d| + d.update_columns(investment_amount_cents: nil) + end + end + + it "backfills from company_investor.investment_amount_in_cents" do + service.perform + expect(dividend.reload.investment_amount_cents).to eq(500_00) + end + end + + context "when dividends are convertible/SAFE-based" do + let(:company_investor) { create(:company_investor, company:, investment_amount_in_cents: 0) } + let(:convertible_investment) { create(:convertible_investment, company:) } + + let!(:convertible_security) do + create(:convertible_security, + company_investor:, + convertible_investment:, + principal_value_in_cents: 250_000_00) + end + + let!(:dividend) do + create(:dividend, company:, company_investor:, dividend_round:, number_of_shares: nil, total_amount_in_cents: 5000, investment_amount_cents: 0).tap do |d| + d.update_columns(investment_amount_cents: nil) + end + end + + it "backfills from convertible_securities.principal_value_in_cents" do + service.perform + expect(dividend.reload.investment_amount_cents).to eq(250_000_00) + end + end + + context "when dividends already have investment_amount_cents" do + let(:company_investor) { create(:company_investor, company:, investment_amount_in_cents: 100_00) } + + let!(:dividend) do + create(:dividend, company:, company_investor:, dividend_round:, investment_amount_cents: 999_99) + end + + it "does not overwrite existing values" do + service.perform + expect(dividend.reload.investment_amount_cents).to eq(999_99) + end + end + + context "when a company_investor has no shares or securities" do + let(:company_investor) { create(:company_investor, company:, investment_amount_in_cents: 0) } + + let!(:dividend) do + create(:dividend, company:, company_investor:, dividend_round:, number_of_shares: nil, total_amount_in_cents: 1000, investment_amount_cents: 0).tap do |d| + d.update_columns(investment_amount_cents: nil) + end + end + + it "falls back to 0" do + service.perform + expect(dividend.reload.investment_amount_cents).to eq(0) + end + end + + context "with multiple dividends across different investors" do + let(:share_investor) { create(:company_investor, company:, investment_amount_in_cents: 300_00) } + let(:safe_investor) { create(:company_investor, company:, investment_amount_in_cents: 0) } + let(:convertible_investment) { create(:convertible_investment, company:) } + + let!(:convertible_security) do + create(:convertible_security, + company_investor: safe_investor, + convertible_investment:, + principal_value_in_cents: 100_000_00) + end + + let!(:share_dividend) do + create(:dividend, company:, company_investor: share_investor, dividend_round:, number_of_shares: 50).tap do |d| + d.update_columns(investment_amount_cents: nil) + end + end + + let!(:safe_dividend) do + create(:dividend, company:, company_investor: safe_investor, dividend_round:, number_of_shares: nil, total_amount_in_cents: 5000, investment_amount_cents: 0).tap do |d| + d.update_columns(investment_amount_cents: nil) + end + end + + it "backfills both correctly" do + service.perform + expect(share_dividend.reload.investment_amount_cents).to eq(300_00) + expect(safe_dividend.reload.investment_amount_cents).to eq(100_000_00) + end + end + end +end diff --git a/frontend/app/(dashboard)/equity/dividend_rounds/[type]/[id]/page.tsx b/frontend/app/(dashboard)/equity/dividend_rounds/[type]/[id]/page.tsx index 8d9635e60b..deae14771a 100644 --- a/frontend/app/(dashboard)/equity/dividend_rounds/[type]/[id]/page.tsx +++ b/frontend/app/(dashboard)/equity/dividend_rounds/[type]/[id]/page.tsx @@ -42,9 +42,6 @@ const DividendRound = ({ id }: { id: string }) => { dividendRoundId: id, }); - // Old dividend records don't have an investment amount - const hasInvestmentAmounts = dividends.some((dividend) => dividend.investmentAmountCents !== null); - const columnHelper = createColumnHelper(); const columns = useMemo( () => [ @@ -54,18 +51,14 @@ const DividendRound = ({ id }: { id: string }) => { cell: (info) =>
{info.getValue() || "Unknown"}
, footer: "Total", }), - ...(hasInvestmentAmounts - ? [ - columnHelper.accessor("investmentAmountCents", { - header: "Investment amount", - cell: (info) => formatMoneyFromCents(Number(info.getValue())), - meta: { numeric: true }, - footer: formatMoneyFromCents( - dividends.reduce((sum, dividend) => sum + Number(dividend.investmentAmountCents), 0), - ), - }), - ] - : []), + columnHelper.accessor("investmentAmountCents", { + header: "Investment amount", + cell: (info) => formatMoneyFromCents(Number(info.getValue())), + meta: { numeric: true }, + footer: formatMoneyFromCents( + dividends.reduce((sum, dividend) => sum + Number(dividend.investmentAmountCents), 0), + ), + }), columnHelper.accessor("totalAmountInCents", { header: "Return amount", cell: (info) => formatMoney(Number(info.getValue()) / 100), diff --git a/frontend/app/(dashboard)/equity/dividends/page.tsx b/frontend/app/(dashboard)/equity/dividends/page.tsx index 4457da7f31..4b907a2e9b 100644 --- a/frontend/app/(dashboard)/equity/dividends/page.tsx +++ b/frontend/app/(dashboard)/equity/dividends/page.tsx @@ -134,7 +134,7 @@ export default function Dividends() { columnHelper.simple( "investmentAmountCents", "Investment amount", - (value) => (value ? formatMoneyFromCents(value) : "N/A"), + (value) => formatMoneyFromCents(value), "numeric", ), columnHelper.simple("totalAmountInCents", "Gross amount", (value) => formatMoneyFromCents(value), "numeric"), diff --git a/frontend/db/schema.ts b/frontend/db/schema.ts index 4e7a2f10dc..15aecd2d65 100644 --- a/frontend/db/schema.ts +++ b/frontend/db/schema.ts @@ -447,7 +447,7 @@ export const dividends = pgTable( userComplianceInfoId: bigint("user_compliance_info_id", { mode: "bigint" }), qualifiedAmountCents: bigint("qualified_amount_cents", { mode: "bigint" }).notNull(), signedReleaseAt: timestamp("signed_release_at", { precision: 6, mode: "date" }), - investmentAmountCents: bigint("investment_amount_cents", { mode: "bigint" }), + investmentAmountCents: bigint("investment_amount_cents", { mode: "bigint" }).notNull(), externalId: varchar("external_id").$default(nanoid).notNull(), }, (table) => [