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
7 changes: 7 additions & 0 deletions backend/app/mailers/user_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ def tax_id_validation_success(user_id)
mail(to: @user.email, subject: "✅ Thanks for updating your tax information")
end

def tin_reverification_required(user_id)
@user = User.find(user_id)
@settings_url = "#{PROTOCOL}://#{DOMAIN}/settings/tax"

mail(to: @user.email, subject: "Action required: Update your tax information")
end

def tax_form_review_reminder(user_compliance_info_id, company_id, tax_year)
@user_compliance_info = UserComplianceInfo.find(user_compliance_info_id)
@company = Company.find(company_id)
Expand Down
4 changes: 2 additions & 2 deletions backend/app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class User < ApplicationRecord

NON_TAX_COMPLIANCE_ATTRIBUTES = %i[legal_name birth_date country_code citizenship_country_code street_address city state zip_code]
USER_PROVIDED_TAX_ATTRIBUTES = %i[tax_id business_entity business_name business_type tax_classification]
TAX_ATTRIBUTES = USER_PROVIDED_TAX_ATTRIBUTES + %i[tax_id_status tax_information_confirmed_at]
TAX_ATTRIBUTES = USER_PROVIDED_TAX_ATTRIBUTES + %i[tax_id_status tax_information_confirmed_at requires_tin_reverification]
COMPLIANCE_ATTRIBUTES = NON_TAX_COMPLIANCE_ATTRIBUTES + USER_PROVIDED_TAX_ATTRIBUTES
CONSULTING_CONTRACT_ATTRIBUTES = %i[email legal_name business_entity business_name street_address city state zip_code country_code citizenship_country_code] # should include all attributes that are referenced in the consulting contract
MAX_MINIMUM_DIVIDEND_PAYMENT_IN_CENTS = 1_000_00
Expand Down Expand Up @@ -110,7 +110,7 @@ def compliance_attributes
end

def has_verified_tax_id?
tax_id.present? && (!requires_w9? || tax_id_status == UserComplianceInfo::TAX_ID_STATUS_VERIFIED)
tax_id.present? && (!requires_w9? || tax_id_status == UserComplianceInfo::TAX_ID_STATUS_VERIFIED) && !requires_tin_reverification
Copy link
Member Author

Choose a reason for hiding this comment

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

Set has_verified_tax_id to false if requires_tin_reverification is true to prevent flagged users to receive any payments

end

def company_administrator_for(company)
Expand Down
5 changes: 4 additions & 1 deletion backend/app/models/user_compliance_info.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ def update_tax_id_status
tax_status_related_attributes = %w[legal_name business_name business_entity tax_id]

if persisted?
self.tax_id_status = nil if tax_status_related_attributes.any? { send("#{_1}_changed?") }
if tax_status_related_attributes.any? { send("#{_1}_changed?") }
self.tax_id_status = nil
self.requires_tin_reverification = false if requires_tin_reverification?
Copy link
Member Author

Choose a reason for hiding this comment

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

Clear re-verification flag if user updates their tax information

end
elsif prior_compliance_info.present? && prior_compliance_info.attributes.values_at(*tax_status_related_attributes) == attributes.values_at(*tax_status_related_attributes)
self.tax_id_status = prior_compliance_info.tax_id_status
end
Expand Down
3 changes: 2 additions & 1 deletion backend/app/presenters/user_presenter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class UserPresenter
:legal_name, :preferred_name, :display_name, :billing_entity_name, :unconfirmed_email,
:created_at, :state, :city, :zip_code, :street_address, :bank_account, :contracts, :tax_id, :birth_date,
:requires_w9?, :tax_information_confirmed_at, :minimum_dividend_payment_in_cents, :bank_accounts,
:tax_id_status, private: true, to: :user, allow_nil: true
:tax_id_status, :requires_tin_reverification, private: true, to: :user, allow_nil: true

def initialize(current_context:)
@current_context = current_context
Expand Down Expand Up @@ -118,6 +118,7 @@ def logged_in_user
email: user.display_email,
onboardingPath: worker && worker.role.nil? ? "/documents" : nil,
taxInformationConfirmedAt: tax_information_confirmed_at&.iso8601,
requiresTinReverification: !!requires_tin_reverification,
isImpersonating: Current.impersonated_user.present?,
githubUsername: user.github_username,
}
Expand Down
99 changes: 99 additions & 0 deletions backend/app/services/onetime/flag_cp2100a_tin_reverification.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# frozen_string_literal: true

# Onetime script to flag users from IRS CP 2100A notice for TIN re-verification
# Usage:
# tins = %w[111111111 222222222]
# Onetime::FlagCp2100aTinReverification.perform(tins:, dry_run: true) # Preview only
# Onetime::FlagCp2100aTinReverification.perform(tins:, dry_run: false) # Actually flag users
class Onetime::FlagCp2100aTinReverification
def self.perform(tins:, dry_run: true)
if dry_run
puts "DRY RUN MODE - No changes will be made"
puts ""
else
puts "LIVE MODE - Users will be flagged for TIN re-verification"
puts ""
end

puts "Processing #{tins.count} TINs from IRS CP 2100A notice..."
puts "================================================================================"

found_users = []
not_found_tins = []

all_compliance_infos = UserComplianceInfo.alive.where.not(tax_id: nil).includes(:user)

tins.each do |tin|
compliance_info = all_compliance_infos.find { |ci| ci.tax_id == tin }

if compliance_info.nil?
not_found_tins << tin
next
end

user = compliance_info.user
found_users << {
user:,
compliance_info:,
tin:,
}
end

puts "\n=== FOUND USERS (#{found_users.count}) ==="
puts "================================================================================"

if found_users.any?
found_users.each_with_index do |data, index|
user = data[:user]
tin = data[:tin]

puts "\n#{index + 1}. User ID: #{user.id}"
puts " Email: #{user.email}"
puts " Name: #{user.name}"
puts " Legal Name: #{user.legal_name}"
puts " TIN: ***#{tin[-4..]}"
puts " Current Status: #{data[:compliance_info].requires_tin_reverification ? 'Already flagged' : 'Not flagged'}"

if dry_run
puts " [DRY RUN] Would flag this user for TIN re-verification and send email"
else
begin
data[:compliance_info].update!(
requires_tin_reverification: true,
tax_id_status: nil
)
UserMailer.tin_reverification_required(user.id).deliver_later
puts " ✓ Flagged for TIN re-verification and email sent"
rescue => e
puts " ✗ Error: #{e.message}"
end
end
end
else
puts "No users found with the provided TINs"
end

if not_found_tins.any?
puts "\n=== NOT FOUND TINS (#{not_found_tins.count}) ==="
puts "================================================================================"
not_found_tins.each do |tin|
puts " ***#{tin[-4..]}"
end
end

puts "\n================================================================================"
puts "SUMMARY:"
puts "================================================================================"
puts " Total TINs processed: #{tins.count}"
puts " Users found: #{found_users.count}"
puts " TINs not found: #{not_found_tins.count}"

if dry_run
puts "\n✓ Dry run complete! No changes were made."
puts " To actually flag users and send emails, run with dry_run: false"
else
puts "\n✓ Flagging complete!"
puts " Users have been flagged for TIN re-verification and emails have been sent."
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<h1>IRS Notice: Action Required</h1>
<p>We received a <strong>CP 2100A notice from the IRS</strong> regarding a mismatch between your <%= @user.business_entity? ? "business name" : "legal name" %> and <%= @user.tax_id_name %> on file.</p>

<p>To continue receiving payments without tax withholding, please log in to Flexile and re-enter your tax information. <strong>Your legal name must match your IRS records exactly.</strong></p>

<p><strong>Important:</strong> Until you update your information, you will not be able to receive payments.</p>

<%= link_to "Update your tax info", @settings_url, class: "button" %>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddTinReverificationFieldsToUserComplianceInfos < ActiveRecord::Migration[8.0]
def change
add_column :user_compliance_infos, :requires_tin_reverification, :boolean, default: false, null: false
end
end
3 changes: 2 additions & 1 deletion 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_02_034515) 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 @@ -836,6 +836,7 @@
t.boolean "business_entity", default: false
t.integer "business_type"
t.integer "tax_classification"
t.boolean "requires_tin_reverification", default: false, null: false
t.index ["user_id"], name: "index_user_compliance_infos_on_user_id"
end

Expand Down
18 changes: 18 additions & 0 deletions backend/spec/models/user_compliance_info_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,24 @@
user_compliance_info.update!(legal_name: "Elmer Fudd")
end.to change { user_compliance_info.reload.tax_id_status }.from(UserComplianceInfo::TAX_ID_STATUS_INVALID).to(nil)
end

context "when user is flagged for TIN re-verification" do
it "clears the re-verification flag when tax-related attributes are updated" do
user_compliance_info.update!(requires_tin_reverification: true, tax_id_status: nil)

expect do
user_compliance_info.update!(legal_name: "New Name")
end.to change { user_compliance_info.reload.requires_tin_reverification }.from(true).to(false)
end

it "does not clear the flag if tax-related attributes are unchanged" do
user_compliance_info.update!(requires_tin_reverification: true, tax_id_status: nil)

expect do
user_compliance_info.update!(street_address: "456 New St")
end.not_to change { user_compliance_info.reload.requires_tin_reverification }
end
end
end
end
end
Expand Down
11 changes: 11 additions & 0 deletions backend/spec/models/user_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,12 @@
user.compliance_info.save(validate: false) # bypass validation
expect(user.reload.has_verified_tax_id?).to eq false
end

it "returns false if the user is flagged for TIN re-verification" do
user.compliance_info.update!(tax_id_status: UserComplianceInfo::TAX_ID_STATUS_VERIFIED)
user.compliance_info.update!(requires_tin_reverification: true, tax_id_status: nil)
expect(user.reload.has_verified_tax_id?).to eq false
end
end

context "for users outside of the US" do
Expand All @@ -383,6 +389,11 @@
user.compliance_info.save(validate: false) # bypass validation
expect(user.reload.has_verified_tax_id?).to eq false
end

it "returns false if the user is flagged for TIN re-verification" do
user.compliance_info.update!(requires_tin_reverification: true, tax_id_status: nil)
expect(user.reload.has_verified_tax_id?).to eq false
end
end
end

Expand Down
14 changes: 14 additions & 0 deletions frontend/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,20 @@ function DashboardLayout({ children }: { children: React.ReactNode }) {
logout();
};

React.useEffect(() => {
if (user.requiresTinReverification && pathname !== "/settings/tax") {
router.replace("/settings/tax");
Copy link
Member Author

Choose a reason for hiding this comment

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

Redirect users flagged for TIN re-verification to the tax settings page to forced them to re-enter their TIN and name

}
}, [user.requiresTinReverification, pathname, router]);

if (user.requiresTinReverification && pathname !== "/settings/tax") {
return (
<div className="flex h-screen items-center justify-center">
<p className="text-muted-foreground">Redirecting...</p>
Copy link
Member Author

Choose a reason for hiding this comment

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

Block rendering while redirecting

</div>
);
}

return (
<SidebarProvider>
<Sidebar collapsible="offcanvas" mobileSidebar={<MobileBottomNav />}>
Expand Down
14 changes: 14 additions & 0 deletions frontend/app/settings/tax/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,20 @@ export default function TaxPage() {
</p>
</hgroup>
<div className="grid gap-4">
{user.requiresTinReverification ? (
<Alert variant="destructive">
<AlertTriangle />
<AlertTitle>IRS Notice: Action Required</AlertTitle>
<AlertDescription>
We received a notice from the IRS that the name and/or TIN on file may not match their records. Please
re-enter your information below. The name <strong>must match exactly</strong> the name under which
your TIN was issued by the IRS.
<br />
<strong className="mt-2 block">You will not receive any payments until this is resolved.</strong>
</AlertDescription>
</Alert>
) : null}

{!isTaxInfoConfirmed && (
<Alert variant="destructive">
<AlertTriangle />
Expand Down
1 change: 1 addition & 0 deletions frontend/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1600,6 +1600,7 @@ export const userComplianceInfos = pgTable(
businessEntity: boolean("business_entity").default(false),
businessType: integer("business_type").$type<BusinessType>(),
taxClassification: integer("tax_classification").$type<TaxClassification>(),
requiresTinReverification: boolean("requires_tin_reverification").notNull().default(false),
},
(table) => [
index("index_user_compliance_infos_on_user_id").using("btree", table.userId.asc().nullsLast().op("int8_ops")),
Expand Down
1 change: 1 addition & 0 deletions frontend/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export const currentUserSchema = z.object({
hasPayoutMethodForInvoices: z.boolean(),
hasPayoutMethodForDividends: z.boolean(),
taxInformationConfirmedAt: z.string().nullable(),
requiresTinReverification: z.boolean(),
isImpersonating: z.boolean(),
githubUsername: z.string().nullable(),
roles: z.object({
Expand Down
Loading