Skip to content
Open
1 change: 1 addition & 0 deletions backend/app/models/dividend.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
1 change: 1 addition & 0 deletions backend/app/services/create_investors_and_dividends.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions backend/config/data/seed_templates/gumroad.json
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@
"withheld_tax_cents": 0,
"qualified_amount_cents": 0,
"number_of_shares": 1230,
"investment_amount_cents": 317340,
"status": "Paid"
},
"convertible_securities": [
Expand Down
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions backend/db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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<Dividend>();
const columns = useMemo(
() => [
Expand All @@ -54,18 +51,14 @@ const DividendRound = ({ id }: { id: string }) => {
cell: (info) => <div className="font-light">{info.getValue() || "Unknown"}</div>,
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),
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/(dashboard)/equity/dividends/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
2 changes: 1 addition & 1 deletion frontend/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => [
Expand Down