Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion backend/app/models/company_investor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,23 @@ class CompanyInvestor < ApplicationRecord
scope :with_required_tax_info_for, -> (tax_year:) do
dividends_subquery = Dividend.select("company_investor_id")
.for_tax_year(tax_year)
.not_return_of_capital
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Return of capital dividends are reported on 1099-B, not 1099-DIV. Without this filter, an investor who only received Return of Capital dividends could incorrectly trigger a 1099-DIV

.group("company_investor_id")
.having("SUM(total_amount_in_cents) >= ?", MIN_DIVIDENDS_AMOUNT_FOR_TAX_FORMS)
.having("SUM(dividends.total_amount_in_cents) >= ?", MIN_DIVIDENDS_AMOUNT_FOR_TAX_FORMS)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding the dividends. table qualifier because the .not_return_of_capital scope joins dividend_rounds, and the unqualified total_amount_in_cents column is ambiguous between the two tables


joins(:company).merge(Company.active)
.where(id: dividends_subquery)
end

scope :with_return_of_capital_dividends_for, -> (tax_year:) do
roc_dividends_subquery = Dividend.select("company_investor_id")
.for_tax_year(tax_year)
.return_of_capital

joins(:company).merge(Company.active)
.where(id: roc_dividends_subquery)
end

def cumulative_dividends_roi
return nil unless investment_amount_in_cents.positive?
dividends.sum(:total_amount_in_cents) / investment_amount_in_cents.to_d
Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/dividend.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ class Dividend < ApplicationRecord
scope :pending_signup, -> { where(status: PENDING_SIGNUP) }
scope :paid, -> { where(status: PAID) }
scope :for_tax_year, -> (tax_year) { paid.where("EXTRACT(year from dividends.paid_at) = ?", tax_year) }
scope :return_of_capital, -> { joins(:dividend_round).where(dividend_rounds: { return_of_capital: true }) }
scope :not_return_of_capital, -> { joins(:dividend_round).where(dividend_rounds: { return_of_capital: false }) }

def external_status = status == PROCESSING ? ISSUED : status
def issued? = status == ISSUED
Expand Down
7 changes: 5 additions & 2 deletions backend/app/models/document.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class Document < ApplicationRecord
has_many_attached :attachments

SUPPORTED_TAX_INFORMATION_TYPES = %w[form_w9 form_w8ben form_w8bene].freeze
TAX_FORM_TYPES = %w[form_1099nec form_1099div form_1042s form_w9 form_w8ben form_w8bene].freeze
TAX_FORM_TYPES = %w[form_1099nec form_1099div form_1099b form_1042s form_w9 form_w8ben form_w8bene].freeze

validates_associated :signatures
validates :document_type, presence: true
Expand All @@ -36,9 +36,10 @@ class Document < ApplicationRecord
form_w9: 8,
form_w8ben: 9,
form_w8bene: 10,
form_1099b: 11,
}

scope :irs_tax_forms, -> { tax_document.where(document_type: %i[form_1099nec form_1099div form_1042s]) }
scope :irs_tax_forms, -> { tax_document.where(document_type: %i[form_1099nec form_1099div form_1099b form_1042s]) }
scope :tax_document, -> { where(document_type: TAX_FORM_TYPES) }
scope :unsigned, -> { joins(:signatures).where(signatures: { signed_at: nil }) }

Expand Down Expand Up @@ -74,6 +75,8 @@ def name
"Release Agreement"
when "form_1099div"
"1099-DIV"
when "form_1099b"
"1099-B"
when "form_1042s"
"1042-S"
when "form_w9"
Expand Down
1 change: 1 addition & 0 deletions backend/app/models/user_compliance_info.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def investor_tax_document_type
def mark_deleted!
docs = documents.tax_document.unsigned
docs = docs.where.not(document_type: [:form_1099div, :form_1042s]) if dividends.paid.any?
docs = docs.where.not(document_type: :form_1099b) if dividends.paid.return_of_capital.exists?
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a user re-submits their tax info (W-9), we delete their unsigned tax forms and regenerate them. But if they already have paid Return of Capital dividends, we must preserve the 1099-B, same logic as the existing 1099-DIV/1042-S preservation above

docs.each(&:mark_deleted!)
super
end
Expand Down
199 changes: 199 additions & 0 deletions backend/app/serializers/tax_documents/form_1099b_serializer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# frozen_string_literal: true

class TaxDocuments::Form1099bSerializer < TaxDocuments::BaseSerializer
TAX_FORM_COPIES = %w[A 1 B 2].freeze

def attributes
TAX_FORM_COPIES.each_with_object({}) do |tax_form_copy, result|
result.merge!(form_fields_for(tax_form_copy))
end
end

private
def form_fields_for(tax_form_copy)
page = tax_form_copy == "A" ? "1" : "2"
copy = "Copy#{tax_form_copy}"

result = {
# Payer information
left_col_field(copy, page, "1") => payer_details,
payer_tin_field(copy, page) => payer_tin,
# Recipient information
left_col_field(copy, page, "3") => formatted_recipient_tin,
left_col_field(copy, page, "4") => normalized_tax_field(billing_entity_name),
left_col_field(copy, page, "5") => normalized_street_address,
left_col_field(copy, page, "6") => normalized_tax_field(full_city_address),
# Applicable checkbox on Form 8949
right_col_field(copy, page, "15") => "",
# Box 1a: Description of property
right_col_field(copy, page, "16") => property_description,
# Box 1b: Date acquired
right_col_field(copy, page, "17") => date_acquired_display,
# Box 1c: Date sold or disposed
right_col_field(copy, page, "18") => date_sold_display,
# Box 1d: Proceeds
box_field(copy, page, "Box1d", "19") => proceeds_in_usd.to_s,
# Box 1e: Cost or other basis
right_col_field(copy, page, "20") => cost_basis_display,
}

# Box 2: Short-term or Long-term gain or loss
# Each checkbox widget has a unique appearance value matching its 1-based position
if long_term?
result[box2_field(copy, page, 1)] = "2"
else
result[box2_field(copy, page, 0)] = "1"
end

# Box 5: Noncovered security (private company shares are noncovered)
result[noncovered_field(copy, page)] = "1"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Private company shares are always noncovered securities per IRS rules, meaning the broker is not required to report cost basis to the IRS


# Box 6: Gross proceeds reported to IRS (checkbox index 0)
result[box6_field(copy, page, 0)] = "1"

if dividends_tax_amount_withheld_in_usd > 0
result[box_field(copy, page, "Box4", "23")] = dividends_tax_amount_withheld_in_usd.to_s
end

result
end

# Field path helpers to handle the different container naming across copies
# Copy A and Copy 2 use `_ReadOrder` suffix, Copy 1 and Copy B do not

def left_col_field(copy, page, field_num)
container = read_order_copy?(copy) ? "LeftCol_ReadOrder" : "LeftCol"
"topmostSubform[0].#{copy}[0].#{container}[0].f#{page}_#{field_num}[0]"
end

def payer_tin_field(copy, page)
if copy == "CopyA"
"topmostSubform[0].#{copy}[0].LeftCol_ReadOrder[0].Payers_ReadOrder[0].f#{page}_2[0]"
else
"topmostSubform[0].#{copy}[0].#{read_order_copy?(copy) ? "LeftCol_ReadOrder" : "LeftCol"}[0].f#{page}_2[0]"
end
end

def right_col_field(copy, page, field_num)
container = read_order_copy?(copy) ? "RightCol_ReadOrder" : "RightCol"
"topmostSubform[0].#{copy}[0].#{container}[0].f#{page}_#{field_num}[0]"
end

def box_field(copy, page, box_name, field_num)
container = read_order_copy?(copy) ? "RightCol_ReadOrder" : "RightCol"
box = read_order_copy?(copy) ? "#{box_name}_ReadOrder" : box_name
"topmostSubform[0].#{copy}[0].#{container}[0].#{box}[0].f#{page}_#{field_num}[0]"
end

def box2_field(copy, page, index)
container = read_order_copy?(copy) ? "RightCol_ReadOrder" : "RightCol"
box = read_order_copy?(copy) ? "Box2_ReadOrder" : "Box2"
"topmostSubform[0].#{copy}[0].#{container}[0].#{box}[0].c#{page}_4[#{index}]"
end

def noncovered_field(copy, page)
container = read_order_copy?(copy) ? "RightCol_ReadOrder" : "RightCol"
"topmostSubform[0].#{copy}[0].#{container}[0].c#{page}_6[0]"
end

def box6_field(copy, page, index)
container = read_order_copy?(copy) ? "RightCol_ReadOrder" : "RightCol"
box = read_order_copy?(copy) ? "Box6_ReadOrder" : "Box6"
"topmostSubform[0].#{copy}[0].#{container}[0].#{box}[0].c#{page}_7[#{index}]"
end

def read_order_copy?(copy)
copy.in?(%w[CopyA Copy2])
end

# Data methods

def roc_dividend_amounts_for_tax_year
@_roc_dividend_amounts_for_tax_year ||= investor.dividends
.for_tax_year(tax_year)
.return_of_capital
.pluck(
"SUM(dividends.total_amount_in_cents)",
"SUM(dividends.withheld_tax_cents)",
"SUM(dividends.number_of_shares)",
"SUM(dividends.investment_amount_cents)"
)
.flatten
end

def roc_dividends_for_tax_year
@_roc_dividends_for_tax_year ||= investor.dividends
.for_tax_year(tax_year)
.return_of_capital
.includes(:dividend_round)
end

def investor
@_investor ||= user.company_investor_for(company)
end

def property_description
total_shares = roc_dividend_amounts_for_tax_year[2] || 0
formatted_shares = total_shares == total_shares.to_i ? total_shares.to_i : total_shares
if formatted_shares > 0
"#{formatted_shares} sh. #{company.name}"
else
company.name
end
end

def date_acquired_display
dates = investor.share_holdings.pluck(:originally_acquired_at).compact.uniq
return "" if dates.empty?
dates.size == 1 ? dates.first.strftime("%m/%d/%Y") : "Various"
end

def date_sold_display
dates = roc_dividends_for_tax_year.filter_map(&:paid_at).map(&:to_date).uniq
return "" if dates.empty?
dates.size == 1 ? dates.first.strftime("%m/%d/%Y") : "Various"
end

def proceeds_in_usd
@_proceeds_in_usd ||= ((roc_dividend_amounts_for_tax_year[0] || 0) / 100.to_d).round
end

def cost_basis_in_usd
@_cost_basis_in_usd ||= ((roc_dividend_amounts_for_tax_year[3] || 0) / 100.to_d).round
end

def cost_basis_display
cost_basis_in_usd > 0 ? cost_basis_in_usd.to_s : ""
end

def dividends_tax_amount_withheld_in_usd
@_dividends_tax_amount_withheld_in_usd ||= ((roc_dividend_amounts_for_tax_year[1] || 0) / 100.to_d).round
end

def long_term?
earliest_acquired = investor.share_holdings.minimum(:originally_acquired_at)
latest_sold = roc_dividends_for_tax_year.filter_map(&:paid_at).max
return true unless earliest_acquired && latest_sold
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't have share holding acquisition dates, we default to long-term

(latest_sold.to_date - earliest_acquired.to_date).to_i > 365
end

def payer_details
[
company.name,
company.street_address,
company.city,
company.state,
company.display_country,
company.zip_code,
company.phone_number,
].join(", ")
end

def payer_tin
tin = company.tax_id

raise "No TIN found for company #{company.id}" unless tin.present?

tin[0..1] + "-" + tin[2..8]
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ def form_fields_for(tax_form_copy)
def dividend_amounts_for_tax_year
@_dividend_amounts_for_tax_year ||= investor.dividends
.for_tax_year(tax_year)
.pluck("SUM(total_amount_in_cents), SUM(withheld_tax_cents), SUM(qualified_amount_cents)")
.not_return_of_capital
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excluding Return of Capital dividends here prevents double-reporting

.pluck("SUM(dividends.total_amount_in_cents), SUM(dividends.withheld_tax_cents), SUM(dividends.qualified_amount_cents)")
.flatten
end

Expand Down
114 changes: 114 additions & 0 deletions backend/app/services/irs/form_1099b_data_generator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# frozen_string_literal: true

class Irs::Form1099bDataGenerator < Irs::BaseFormDataGenerator
def payee_ids
@_payee_ids ||= total_amounts_for_tax_year_by_user_compliance_info_id.map { _1["id"] }
end

def type_of_return = "B ".ljust(2) # Proceeds from broker and barter exchange transactions

def amount_codes = "24".ljust(18) # Amount code 2 = Gross proceeds, Amount code 4 = Federal income tax withheld
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per IRS FIRE format spec: Amount Code 2 = Gross proceeds, Amount Code 4 = Federal income tax withheld. These are the only two payment amount fields used for 1099-B broker transactions


def serialize_form_data
result = ""
user_compliance_infos.find_each.with_index(3) do |user_compliance_info, index|
result += serialize_payee_record(user_compliance_info:, index:)
end
result + end_of_issuer_record
end

private
def user_compliance_infos
return @_user_compliance_infos if defined?(@_user_compliance_infos)

@_user_compliance_infos = UserComplianceInfo.includes(:user)
.joins(:documents)
.where(documents:
{
company:,
year: tax_year,
document_type: :form_1099b,
deleted_at: nil,
})
.where(country_code: "US")
end

def total_amounts_for_tax_year_by_user_compliance_info_id
sql = user_compliance_infos.joins(:dividends)
.merge(Dividend.for_tax_year(tax_year).return_of_capital)
.select("user_compliance_infos.id," \
"SUM(dividends.total_amount_in_cents) AS total_proceeds_in_cents," \
"SUM(dividends.withheld_tax_cents) AS withheld_tax_in_cents")
.group("user_compliance_infos.id")
.to_sql
@_total_amounts_for_tax_year_by_user_compliance_info_id ||= ApplicationRecord.connection.execute(sql).to_a
end

def serialize_payee_record(user_compliance_info:, index:)
user_name = normalized_tax_field(user_compliance_info.legal_name)
first_name = user_name.split[0..-2].join(" ")
last_name = user_name.split.last
type_of_tin = user_compliance_info.business_entity? ? "1" : "2"
name_control = user_compliance_info.business_entity? ? user_compliance_info.business_name.upcase : last_name
payee_amounts = total_amounts_for_tax_year_by_user_compliance_info_id.find { _1["id"] == user_compliance_info.id }
total_proceeds_for_payee = payee_amounts["total_proceeds_in_cents"].to_i.to_s.rjust(12, "0")
withheld_tax_for_payee = payee_amounts["withheld_tax_in_cents"].to_i.to_s.rjust(12, "0")

[
"B",
tax_year.to_s,
required_blanks(1), # Corrected return indicator
name_control[0..3].ljust(4), # Payee name control
type_of_tin,
normalized_tax_id_for(user_compliance_info),
user_compliance_info.id.to_s.rjust(20), # Unique issuer account number for payee
required_blanks(14),
"".rjust(12, "0"), # Payment amount 1 (unused)
total_proceeds_for_payee, # Payment amount 2 (Gross proceeds)
"".rjust(12, "0"), # Payment amount 3 (unused)
withheld_tax_for_payee, # Payment amount 4 (Federal income tax withheld)
"".rjust(144, "0"), # Remaining payment amount fields (5-16)
required_blanks(17),
"#{last_name} #{first_name}".ljust(80),
normalized_tax_field(user_compliance_info.street_address, 40),
required_blanks(40),
normalized_tax_field(user_compliance_info.city, 40),
user_compliance_info.state,
normalized_tax_field(user_compliance_info.zip_code, 9),
required_blanks(1),
sequence_number(index),
required_blanks(215),
"".rjust(24, "0"), # Unused state + local tax withheld amount fields
required_blanks(2),
"\n\n",
].join
end

def end_of_issuer_record
offset = 3 # 1 for transmitter record, 1 for issuer record, 1 for payee records
[
"C",
payee_ids.count.to_s.rjust(8, "0"),
required_blanks(6),
"".rjust(18, "0"), # Payment amount 1 total (unused)
total_amounts_for_tax_year_by_user_compliance_info_id.map { _1["total_proceeds_in_cents"] }.sum.to_i.to_s.rjust(18, "0"), # Payment amount 2 total (Gross proceeds)
"".rjust(18, "0"), # Payment amount 3 total (unused)
total_amounts_for_tax_year_by_user_compliance_info_id.map { _1["withheld_tax_in_cents"] }.sum.to_i.to_s.rjust(18, "0"), # Payment amount 4 total (Federal income tax withheld)
"".rjust(216, "0"), # Remaining amount totals (5-16)
required_blanks(160),
sequence_number(payee_ids.count + offset),
required_blanks(241),
"\n\n",
].join
end
end

### Usage:
=begin
company = Company.find(company_id)
transmitter_company = Company.find(transmitter_company_id)
tax_year = 2025
is_test = false
attached = { "IRS-1099-B-#{tax_year}.txt" => Irs::Form1099bDataGenerator.new(company:, transmitter_company:, tax_year:, is_test:).process }
AdminMailer.custom(to: ["admin@example.com"], subject: "[Flexile] 1099-B #{tax_year} IRS FIRE tax report #{is_test ? "test " : ""}file", body: "Attached", attached:).deliver_now
=end
Loading
Loading