-
Notifications
You must be signed in to change notification settings - Fork 384
Add 1099-B tax form #1616
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add 1099-B tax form #1616
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
| .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) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding the |
||
|
|
||
| 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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? | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
| 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" | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
|---|---|---|
|
|
@@ -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 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
|
|
||
| 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 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment.
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