diff --git a/backend/app/models/company_investor.rb b/backend/app/models/company_investor.rb index 6fcca6ec2f..aeb074310c 100644 --- a/backend/app/models/company_investor.rb +++ b/backend/app/models/company_investor.rb @@ -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) 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 diff --git a/backend/app/models/dividend.rb b/backend/app/models/dividend.rb index 756a4972f6..64c5949d7a 100644 --- a/backend/app/models/dividend.rb +++ b/backend/app/models/dividend.rb @@ -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 diff --git a/backend/app/models/document.rb b/backend/app/models/document.rb index 52cd984c6c..7b96d613fe 100644 --- a/backend/app/models/document.rb +++ b/backend/app/models/document.rb @@ -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 @@ -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 }) } @@ -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" diff --git a/backend/app/models/user_compliance_info.rb b/backend/app/models/user_compliance_info.rb index e190c035cd..6ecdc99bde 100644 --- a/backend/app/models/user_compliance_info.rb +++ b/backend/app/models/user_compliance_info.rb @@ -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? docs.each(&:mark_deleted!) super end diff --git a/backend/app/serializers/tax_documents/form_1099b_serializer.rb b/backend/app/serializers/tax_documents/form_1099b_serializer.rb new file mode 100644 index 0000000000..eeb8abcb22 --- /dev/null +++ b/backend/app/serializers/tax_documents/form_1099b_serializer.rb @@ -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" + + # 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 + (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 diff --git a/backend/app/serializers/tax_documents/form_1099div_serializer.rb b/backend/app/serializers/tax_documents/form_1099div_serializer.rb index cab50dce76..6cb41c8313 100644 --- a/backend/app/serializers/tax_documents/form_1099div_serializer.rb +++ b/backend/app/serializers/tax_documents/form_1099div_serializer.rb @@ -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 + .pluck("SUM(dividends.total_amount_in_cents), SUM(dividends.withheld_tax_cents), SUM(dividends.qualified_amount_cents)") .flatten end diff --git a/backend/app/services/irs/form_1099b_data_generator.rb b/backend/app/services/irs/form_1099b_data_generator.rb new file mode 100644 index 0000000000..2defbb96ce --- /dev/null +++ b/backend/app/services/irs/form_1099b_data_generator.rb @@ -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 + + 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 diff --git a/backend/app/services/irs/form_1099div_data_generator.rb b/backend/app/services/irs/form_1099div_data_generator.rb index c2a45a6038..74468cc39e 100644 --- a/backend/app/services/irs/form_1099div_data_generator.rb +++ b/backend/app/services/irs/form_1099div_data_generator.rb @@ -35,7 +35,7 @@ def user_compliance_infos def total_amounts_for_tax_year_by_user_compliance_info_id sql = user_compliance_infos.joins(:dividends) - .merge(Dividend.for_tax_year(tax_year)) + .merge(Dividend.for_tax_year(tax_year).not_return_of_capital) .select("user_compliance_infos.id," \ "SUM(dividends.total_amount_in_cents) AS total_amount_in_cents," \ "SUM(dividends.qualified_amount_cents) AS qualified_amount_in_cents," \ diff --git a/backend/app/sidekiq/generate_irs_tax_forms_job.rb b/backend/app/sidekiq/generate_irs_tax_forms_job.rb index 1758d58807..310092d8a6 100644 --- a/backend/app/sidekiq/generate_irs_tax_forms_job.rb +++ b/backend/app/sidekiq/generate_irs_tax_forms_job.rb @@ -23,6 +23,10 @@ def perform(user_compliance_info_id, tax_year = Date.current.year - 1) document_type: user_compliance_info.investor_tax_document_type, tax_year:, ) + + if user_compliance_info.requires_w9? + generate_return_of_capital_tax_forms(user_compliance_info:, user:, tax_year:) + end end private @@ -37,4 +41,16 @@ def generate_tax_forms(company_user_klass:, user_compliance_info:, user:, docume GenerateTaxFormService.new(user_compliance_info:, document_type:, tax_year:, company:).process end end + + def generate_return_of_capital_tax_forms(user_compliance_info:, user:, tax_year:) + CompanyInvestor.with_return_of_capital_dividends_for(tax_year:) + .where(user:) + .includes(:company) + .find_each do |company_investor| + company = company_investor.company + next if user.documents.tax_document.alive.where(year: tax_year, document_type: :form_1099b, company:).exists? + + GenerateTaxFormService.new(user_compliance_info:, document_type: :form_1099b, tax_year:, company:).process + end + end end diff --git a/backend/app/sidekiq/tax_form_review_job.rb b/backend/app/sidekiq/tax_form_review_job.rb index 7d3fddacdb..d3861007d7 100644 --- a/backend/app/sidekiq/tax_form_review_job.rb +++ b/backend/app/sidekiq/tax_form_review_job.rb @@ -13,6 +13,7 @@ def perform(tax_year = Date.current.year - 1, send_email = true) collect_tax_form_data_for(CompanyWorker, tax_year, user_compliance_info_ids, company_ids, user_compliance_info_company_ids) collect_tax_form_data_for(CompanyInvestor, tax_year, user_compliance_info_ids, company_ids, user_compliance_info_company_ids) + collect_roc_tax_form_data(tax_year, user_compliance_info_ids, company_ids, user_compliance_info_company_ids) user_compliance_info_ids.each_slice(BATCH_SIZE) do |batch_ids| array_of_args = batch_ids.map { [_1, tax_year] } @@ -42,4 +43,17 @@ def collect_tax_form_data_for(company_user_klass, tax_year, user_compliance_info user_compliance_info_company_ids.add([record.id, record.company_id]) end end + + def collect_roc_tax_form_data(tax_year, user_compliance_info_ids, company_ids, user_compliance_info_company_ids) + UserComplianceInfo.alive + .where(country_code: "US") + .joins(user: :company_investors) + .merge(CompanyInvestor.with_return_of_capital_dividends_for(tax_year:)) + .select(:id, "company_investors.company_id") + .find_each do |record| + user_compliance_info_ids.add(record.id) + company_ids.add(record.company_id) + user_compliance_info_company_ids.add([record.id, record.company_id]) + end + end end diff --git a/backend/config/data/tax_forms/1099-B.pdf b/backend/config/data/tax_forms/1099-B.pdf new file mode 100644 index 0000000000..3c3dfb7ae1 Binary files /dev/null and b/backend/config/data/tax_forms/1099-B.pdf differ diff --git a/backend/spec/models/company_investor_spec.rb b/backend/spec/models/company_investor_spec.rb index 784c67b466..ee4b92e609 100644 --- a/backend/spec/models/company_investor_spec.rb +++ b/backend/spec/models/company_investor_spec.rb @@ -104,6 +104,53 @@ [company_investor_1, company_investor_2, company_investor_3] ) end + + it "excludes investors whose only dividends are return-of-capital" do + roc_round = create(:dividend_round, company:, return_of_capital: true) + roc_only_investor = create(:company_investor, company:, user: create(:user)) + create(:dividend, :paid, company_investor: roc_only_investor, company:, + dividend_round: roc_round, total_amount_in_cents: 500_00) + + expect(described_class.with_required_tax_info_for(tax_year:)).not_to include(roc_only_investor) + end + end + + describe ".with_return_of_capital_dividends_for" do + let(:company) { create(:company) } + let(:tax_year) { Date.current.year } + let(:roc_round) { create(:dividend_round, company:, return_of_capital: true) } + let(:non_roc_round) { create(:dividend_round, company:, return_of_capital: false) } + + let(:company_investor_with_roc) do + create(:company_investor, company:, user: create(:user)) + end + let(:company_investor_without_roc) do + create(:company_investor, company:, user: create(:user)) + end + let(:company_investor_with_roc_other_year) do + create(:company_investor, company:, user: create(:user)) + end + + before do + # Investor with ROC dividend in tax year + create(:dividend, :paid, company_investor: company_investor_with_roc, company:, + dividend_round: roc_round, total_amount_in_cents: 500_00) + + # Investor with non-ROC dividend only + create(:dividend, :paid, company_investor: company_investor_without_roc, company:, + dividend_round: non_roc_round, total_amount_in_cents: 500_00) + + # Investor with ROC dividend in a different year + create(:dividend, :paid, company_investor: company_investor_with_roc_other_year, company:, + dividend_round: roc_round, total_amount_in_cents: 500_00, + created_at: Date.current.prev_year, paid_at: Date.current.prev_year) + end + + it "returns only company investors with return-of-capital dividends for the tax year" do + expect(described_class.with_return_of_capital_dividends_for(tax_year:)).to match_array( + [company_investor_with_roc] + ) + end end end end diff --git a/backend/spec/models/dividend_spec.rb b/backend/spec/models/dividend_spec.rb index 5ec2c43f32..e6ac0db6d0 100644 --- a/backend/spec/models/dividend_spec.rb +++ b/backend/spec/models/dividend_spec.rb @@ -58,6 +58,24 @@ expect(described_class.for_tax_year(tax_year)).to eq([dividend_in_tax_year]) end end + + describe ".return_of_capital" do + let!(:roc_dividend) { create(:dividend, dividend_round: create(:dividend_round, return_of_capital: true)) } + let!(:non_roc_dividend) { create(:dividend, dividend_round: create(:dividend_round, return_of_capital: false)) } + + it "returns only dividends from return-of-capital rounds" do + expect(described_class.return_of_capital).to eq([roc_dividend]) + end + end + + describe ".not_return_of_capital" do + let!(:roc_dividend) { create(:dividend, dividend_round: create(:dividend_round, return_of_capital: true)) } + let!(:non_roc_dividend) { create(:dividend, dividend_round: create(:dividend_round, return_of_capital: false)) } + + it "returns only dividends from non-return-of-capital rounds" do + expect(described_class.not_return_of_capital).to eq([non_roc_dividend]) + end + end end describe "#external_status" do diff --git a/backend/spec/models/user_compliance_info_spec.rb b/backend/spec/models/user_compliance_info_spec.rb index 3d7bb037e4..9c8505d46c 100644 --- a/backend/spec/models/user_compliance_info_spec.rb +++ b/backend/spec/models/user_compliance_info_spec.rb @@ -456,6 +456,29 @@ end end end + + context "when there are paid return-of-capital dividends attached to the user compliance info" do + let!(:form_1099_b) { create(:document, document_type: :form_1099b, user_compliance_info:) } + let(:roc_round) { create(:dividend_round, return_of_capital: true) } + + before { create(:dividend, :paid, user_compliance_info:, dividend_round: roc_round) } + + it "preserves 1099-B documents" do + user_compliance_info.mark_deleted! + expect(user_compliance_info.reload).to be_deleted + expect(form_1099_nec.reload).to be_deleted + expect(form_1099_b.reload).to_not be_deleted + end + end + + context "when there are no return-of-capital dividends" do + let!(:form_1099_b) { create(:document, document_type: :form_1099b, user_compliance_info:, signed: false) } + + it "deletes unsigned 1099-B documents" do + user_compliance_info.mark_deleted! + expect(form_1099_b.reload).to be_deleted + end + end end let!(:tax_document) { create(:document, document_type: :form_w9, user_compliance_info:) } diff --git a/backend/spec/services/generate_tax_form_service_spec.rb b/backend/spec/services/generate_tax_form_service_spec.rb index 5bdcd2c658..bb59fd01ba 100644 --- a/backend/spec/services/generate_tax_form_service_spec.rb +++ b/backend/spec/services/generate_tax_form_service_spec.rb @@ -505,6 +505,67 @@ end end + context "when form is a 1099-B" do + let(:document_type) { "form_1099b" } + let(:business_entity) { false } + let(:business_name) { nil } + let(:business_type) { nil } + let(:tax_classification) { nil } + let(:user_compliance_info) do + create(:user_compliance_info, :us_resident, :confirmed, user:, tax_id: "123456789", + business_entity:, business_name:, business_type:, tax_classification:) + end + let!(:company_investor) { create(:company_investor, company:, user:) } + let!(:roc_round) { create(:dividend_round, company:, return_of_capital: true) } + + before do + create(:share_holding, company_investor:, originally_acquired_at: Date.new(tax_year - 2, 3, 15)) + + create(:dividend, :paid, company_investor:, company:, user_compliance_info:, + dividend_round: roc_round, + total_amount_in_cents: 500_00, + number_of_shares: 50, + investment_amount_cents: 400_00, + withheld_tax_cents: 0, + created_at: Date.new(tax_year, 6, 1), + paid_at: Date.new(tax_year, 6, 15)) + create(:dividend, :paid, company_investor:, company:, user_compliance_info:, + dividend_round: roc_round, + total_amount_in_cents: 300_00, + number_of_shares: 30, + investment_amount_cents: 250_00, + withheld_tax_cents: 0, + created_at: Date.new(tax_year, 9, 1), + paid_at: Date.new(tax_year, 9, 15)) + end + + it_behaves_like "existing tax document" + + it "creates a tax document with the correct attributes" do + expect do + expect(generate_tax_form_service.process).to be_an_instance_of(Document) + end.to change { user_compliance_info.documents.tax_document.count }.by(1) + + tax_document = user_compliance_info.documents.tax_document.last + expect(tax_document.document_type).to eq(document_type) + expect(tax_document.year).to eq(tax_year) + expect(tax_document.company_id).to eq(company.id) + expect(tax_document.live_attachment.filename).to eq("#{tax_year}-1099-B-#{company.name.parameterize}-#{user.billing_entity_name.parameterize}.pdf") + + tax_document.live_attachment.open do |file| + pdf = HexaPDF::Document.new(io: file) + expect(pdf.pages.count).to be > 0 + + processor = CustomPDFTextExtractor.new + pdf.pages.each { _1.process_contents(processor) } + + text = processor.texts.join("\n") + expect(text).to include("45-3361423") # payer TIN + expect(text).to include("Jane Flex") + end + end + end + context "when form is a 1042-S" do let(:document_type) { "form_1042s" } let(:business_entity) { false } diff --git a/backend/spec/services/irs/form_1099b_data_generator_spec.rb b/backend/spec/services/irs/form_1099b_data_generator_spec.rb new file mode 100644 index 0000000000..69da33ed0f --- /dev/null +++ b/backend/spec/services/irs/form_1099b_data_generator_spec.rb @@ -0,0 +1,353 @@ +# frozen_string_literal: true + +RSpec.describe Irs::Form1099bDataGenerator do + let(:tax_year) { 2023 } + let!(:transmitter_company) do + create( + :company, + :completed_onboarding, + email: "hi@gumroad.com", + name: "Gumroad", + tax_id: "453361423", + street_address: "548 Market St", + city: "San Francisco", + state: "CA", + zip_code: "94105", + country_code: "US", + phone_number: "555-123-4567" + ) + end + let(:company) do + create( + :company, + :completed_onboarding, + email: "hi@acme.com", + name: "Acme, Inc.", + tax_id: "123456789", + street_address: "123 Main St", + city: "New York", + state: "NY", + zip_code: "10001", + country_code: "US", + phone_number: "555-123-4567" + ) + end + let(:us_resident) do + user = create(:user, :without_compliance_info) + create(:company_investor, company:, user:) + user + end + let(:us_resident_2) do + user = create(:user, :without_compliance_info) + create(:company_investor, company:, user:) + user + end + let(:non_us_resident) do + user = create(:user, :without_compliance_info, country_code: "FR") + create(:company_investor, company:, user:) + user + end + let!(:user_compliance_info) do + create(:user_compliance_info, :us_resident, user: us_resident, tax_information_confirmed_at: 1.day.ago, deleted_at: 1.hour.ago) + create(:user_compliance_info, :us_resident, :confirmed, user: us_resident) + end + let!(:user_compliance_info_2) do + create(:user_compliance_info, :us_resident, user: us_resident_2, city: "APO", state: "AE", tax_information_confirmed_at: 1.day.ago, deleted_at: 1.hour.ago) + end + let!(:non_us_user_compliance_info) { create(:user_compliance_info, :non_us_resident, :confirmed, user: non_us_resident) } + let!(:roc_dividend_round) { create(:dividend_round, company:, return_of_capital: true) } + let!(:non_roc_dividend_round) { create(:dividend_round, company:, return_of_capital: false) } + + subject(:service) { described_class.new(company:, transmitter_company:, tax_year:) } + + before do + company_investor = us_resident.company_investors.first! + + # Return-of-capital dividends for us_resident (should be included) + create(:dividend, :paid, company_investor:, company:, user_compliance_info:, + dividend_round: roc_dividend_round, + total_amount_in_cents: 500_00, + withheld_tax_cents: 0, + created_at: Date.new(tax_year, 6, 1), + paid_at: Date.new(tax_year, 6, 15)) + create(:dividend, :paid, company_investor:, company:, user_compliance_info:, + dividend_round: roc_dividend_round, + total_amount_in_cents: 300_00, + withheld_tax_cents: 0, + created_at: Date.new(tax_year, 9, 1), + paid_at: Date.new(tax_year, 9, 15)) + + # Non-ROC dividend for us_resident (should NOT be included in 1099-B) + create(:dividend, :paid, company_investor:, company:, user_compliance_info:, + dividend_round: non_roc_dividend_round, + total_amount_in_cents: 1000_00, + created_at: Date.new(tax_year, 3, 1), + paid_at: Date.new(tax_year, 3, 15)) + + # Return-of-capital dividend for us_resident_2 with tax withheld + company_investor_2 = us_resident_2.company_investors.first! + create(:dividend, :paid, company_investor: company_investor_2, company:, + user_compliance_info: user_compliance_info_2, + dividend_round: roc_dividend_round, + total_amount_in_cents: 200_00, + withheld_tax_cents: 48_00, + withholding_percentage: 24, + created_at: Date.new(tax_year, 6, 1), + paid_at: Date.new(tax_year, 6, 15)) + + # Non-US resident with ROC dividend (should NOT appear - non-US) + non_us_resident.update!(citizenship_country_code: "FR") + company_investor_3 = non_us_resident.company_investors.first! + create(:dividend, :paid, company_investor: company_investor_3, company:, + user_compliance_info: non_us_user_compliance_info, + dividend_round: roc_dividend_round, + total_amount_in_cents: 500_00, + created_at: Date.new(tax_year, 6, 1), + paid_at: Date.new(tax_year, 6, 15)) + end + + def create_new_tax_documents + create(:document, document_type: :form_1099b, company:, user_compliance_info:, year: tax_year) + create(:document, document_type: :form_1099b, company:, user_compliance_info: user_compliance_info_2, year: tax_year) + end + + def required_blanks(number) = "".ljust(number) + + describe "#process" do + before { create_new_tax_documents } + + context "when there are US investors with return-of-capital dividends for the tax year" do + before do + # ROC dividends for other tax years (should not be included) + create(:dividend, :paid, company:, company_investor: us_resident.company_investors.first!, user_compliance_info:, + dividend_round: roc_dividend_round, + total_amount_in_cents: 500_00, + created_at: Date.new(tax_year - 1, 1, 1), + paid_at: Date.new(tax_year - 1, 1, 1)) + create(:dividend, :paid, company:, company_investor: us_resident.company_investors.first!, user_compliance_info:, + dividend_round: roc_dividend_round, + total_amount_in_cents: 500_00, + created_at: Date.new(tax_year + 1, 1, 1), + paid_at: Date.new(tax_year + 1, 1, 1)) + end + + it "returns a string with the correct form data" do + records = service.process.split("\n\n") + expect(records.size).to eq(6) + + transmitter_record, issuer_record, payee_record_1, payee_record_2, end_of_issuer_record, end_of_transmission_record = records + expect(transmitter_record).to eq( + [ + "T", + tax_year.to_s, + required_blanks(1), # Prior year data indicator + transmitter_company.tax_id, # Payer TIN + GlobalConfig.dig("irs", "tcc_1099"), # Transmitter control code + required_blanks(9), + "GUMROAD".ljust(80), # Transmitter name + "GUMROAD".ljust(80), # Company name + transmitter_company.street_address.upcase.ljust(40), + "SAN FRANCISCO".ljust(40), + "CA", # State code + transmitter_company.zip_code.ljust(9), + required_blanks(15), + "00000002", # Total number of payees + normalized_tax_field(transmitter_company.primary_admin.user.legal_name, 40), # Issuer contact name + transmitter_company.phone_number.delete("-").ljust(15), + transmitter_company.email.ljust(50), + required_blanks(91), + "00000001", # Sequence number + required_blanks(10), + "I", # Vendor indicator + required_blanks(230), + ].join + ) + + expect(issuer_record).to eq( + [ + "A", + tax_year.to_s, + required_blanks(6), + company.tax_id, + "ACME", # Issuer name control + required_blanks(1), + "B ", # Type of return (Broker transactions) + "24".ljust(18), # Amount codes (2 = Gross proceeds, 4 = Federal tax withheld) + required_blanks(7), + normalized_tax_field(company.primary_admin.user.legal_name, 80), # Issuer contact name + "1", # Transfer indicator agent + company.street_address.upcase.ljust(40), + "NEW YORK".ljust(40), + "NY", # State code + company.zip_code.ljust(9), + company.phone_number.delete("-").ljust(15), + required_blanks(260), + "00000002", # Sequence number + required_blanks(241), + ].join + ) + + user_name = normalized_tax_field(user_compliance_info.legal_name).split + last_name = user_name.last + first_name = user_name[0..-2].join(" ") + expect(payee_record_1).to eq( + [ + "B", + tax_year.to_s, + required_blanks(1), # Corrected return indicator + last_name[0..3].ljust(4), # Payee name control + "2", # Type of TIN, 1 = EIN, 2 = SSN + "000000000", # Payee TIN + user_compliance_info.id.to_s.rjust(20), # Unique issuer account number for payee + required_blanks(14), + "".rjust(12, "0"), # Payment amount 1 (unused) + "80000".rjust(12, "0"), # Payment amount 2 (Gross proceeds: 500_00 + 300_00) + "".rjust(12, "0"), # Payment amount 3 (unused) + "".rjust(12, "0"), # Payment amount 4 (Federal tax withheld: 0) + "".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), + "00000003", # Sequence number + required_blanks(215), + "".rjust(24, "0"), # Unused state + local tax withheld amount fields + required_blanks(2), + ].join + ) + + user_name_2 = normalized_tax_field(user_compliance_info_2.legal_name).split + last_name_2 = user_name_2.last + first_name_2 = user_name_2[0..-2].join(" ") + expect(payee_record_2).to eq( + [ + "B", + tax_year.to_s, + required_blanks(1), # Corrected return indicator + last_name_2[0..3].ljust(4), # Payee name control + "2", # Type of TIN, 1 = EIN, 2 = SSN + "000000000", # Payee TIN + user_compliance_info_2.id.to_s.rjust(20), # Unique issuer account number for payee + required_blanks(14), + "".rjust(12, "0"), # Payment amount 1 (unused) + "20000".rjust(12, "0"), # Payment amount 2 (Gross proceeds: 200_00) + "".rjust(12, "0"), # Payment amount 3 (unused) + "4800".rjust(12, "0"), # Payment amount 4 (Federal tax withheld: 48_00) + "".rjust(144, "0"), # Remaining payment amount fields (5-16) + required_blanks(17), + "#{last_name_2} #{first_name_2}".ljust(80), + normalized_tax_field(user_compliance_info_2.street_address, 40), + required_blanks(40), + normalized_tax_field(user_compliance_info_2.city, 40), + "AE", # Military state code + normalized_tax_field(user_compliance_info_2.zip_code, 9), + required_blanks(1), + "00000004", # Sequence number + required_blanks(215), + "".rjust(24, "0"), # Unused state + local tax withheld amount fields + required_blanks(2), + ].join + ) + + expect(end_of_issuer_record).to eq( + [ + "C", + "2".rjust(8, "0"), # Total number of payees + required_blanks(6), + "".rjust(18, "0"), # Payment amount 1 total (unused) + "100000".rjust(18, "0"), # Payment amount 2 total (Gross proceeds: 80000 + 20000) + "".rjust(18, "0"), # Payment amount 3 total (unused) + "4800".rjust(18, "0"), # Payment amount 4 total (Federal tax withheld) + "".rjust(216, "0"), # Remaining amount totals (5-16) + required_blanks(160), + "00000005", # Sequence number + required_blanks(241), + ].join + ) + + expect(end_of_transmission_record).to eq( + [ + "F", + "1".rjust(8, "0"), + "".rjust(21, "0"), + required_blanks(469), + "00000006", # Sequence number + required_blanks(241), + ].join + ) + end + + context "when it is a test file" do + it "includes the test file indicator in the transmitter record" do + expect( + described_class.new(company:, transmitter_company:, tax_year:, is_test: true).process + ).to start_with("T#{tax_year}#{required_blanks(1)}#{transmitter_company.tax_id}#{GlobalConfig.dig("irs", "tcc_1099")}#{required_blanks(7)}T") + end + end + + context "when payee is a business entity" do + before do + us_resident.reload.compliance_info.update!(business_entity: true, business_name: "Acme Inc.", business_type: "s_corporation") + end + + it "includes the business name control and EIN indicator in the payee record" do + records = service.process.split("\n\n") + expect(records.size).to eq(6) + + _, _, payee_record, _, _ = records + expect(payee_record).to start_with("B#{tax_year}#{required_blanks(1)}ACME1") + end + end + end + + context "when there are no return-of-capital dividends" do + before { Dividend.joins(:dividend_round).where(dividend_rounds: { return_of_capital: true }).destroy_all } + + it "returns nil" do + expect(service.process).to be_nil + end + end + end + + describe "#payee_ids" do + before { create_new_tax_documents } + + context "when there are US investors with return-of-capital dividends for the tax year" do + it "returns an array of user compliance info ids" do + expect(service.payee_ids).to match_array([user_compliance_info.id, user_compliance_info_2.id]) + end + end + + context "when there are no return-of-capital dividends" do + before { Dividend.joins(:dividend_round).where(dividend_rounds: { return_of_capital: true }).destroy_all } + + it "returns an empty array" do + expect(service.payee_ids).to eq([]) + end + end + end + + describe "#type_of_return" do + it "returns 'B ' for broker transactions" do + expect(service.type_of_return).to eq("B ") + end + end + + describe "#amount_codes" do + it "returns an 18 long string left justified with the correct amount codes set" do + form_1099b_amount_codes = "24".ljust(18) + expect(service.amount_codes.length).to eq(18) + expect(service.amount_codes).to eq(form_1099b_amount_codes) + end + end + + private + def normalized_tax_field(field, length = nil) + length ||= field.length + I18n.transliterate(field).gsub(/[^0-9A-Za-z\s]/, "").upcase.ljust(length) + end +end diff --git a/frontend/app/(dashboard)/documents/page.tsx b/frontend/app/(dashboard)/documents/page.tsx index 4ca05dc6d3..abde437c83 100644 --- a/frontend/app/(dashboard)/documents/page.tsx +++ b/frontend/app/(dashboard)/documents/page.tsx @@ -55,6 +55,8 @@ const documentName = (document: Document) => { return "1099-NEC"; case DocumentType.Form1099DIV: return "1099-DIV"; + case DocumentType.Form1099B: + return "1099-B"; case DocumentType.Form1042S: return "1042-S"; case DocumentType.FormW9: @@ -91,6 +93,7 @@ function getStatus(document: Document): { name: string; text: string } { return { name: "Signed", text: "Signed" }; case DocumentType.Form1099NEC: case DocumentType.Form1099DIV: + case DocumentType.Form1099B: case DocumentType.Form1042S: return completedAt ? { name: "Signed", text: `Filed on ${formatDate(completedAt)}` } @@ -412,10 +415,11 @@ export default function DocumentsPage() { )} {user.roles.administrator && new Date() <= filingDueDateFor1099DIV ? ( - Upcoming filing dates for 1099-NEC, 1099-DIV, and 1042-S + Upcoming filing dates for 1099-NEC, 1099-DIV, 1099-B, and 1042-S We will submit form 1099-NEC to the IRS on {formatDate(new Date(currentYear, 0, 31))}, form 1042-S on{" "} - {formatDate(new Date(currentYear, 2, 15))}, and form 1099-DIV on {formatDate(filingDueDateFor1099DIV)}. + {formatDate(new Date(currentYear, 2, 15))}, and forms 1099-DIV and 1099-B on{" "} + {formatDate(filingDueDateFor1099DIV)}. ) : null} diff --git a/frontend/db/enums.ts b/frontend/db/enums.ts index 2abd3f107f..30d0bfa947 100644 --- a/frontend/db/enums.ts +++ b/frontend/db/enums.ts @@ -15,6 +15,7 @@ export enum DocumentType { FormW9, FormW8BEN, FormW8BENE, + Form1099B, } export enum DocumentTemplateType {