Skip to content

Auto-suspend creators with shared ACH bank accounts (stripe fingerprints) - #2810

Merged
EmCousin merged 8 commits into
antiwork:mainfrom
yashranaway:feature/auto-suspend-shared-ach-accounts
Jan 28, 2026
Merged

EmCousin merged 8 commits into
antiwork:mainfrom
yashranaway:feature/auto-suspend-shared-ach-accounts

Conversation

@yashranaway

@yashranaway yashranaway commented Jan 10, 2026 •

Copy link
Copy Markdown
Contributor

Fixes : #1158

Description

Problem

We currently auto-suspend accounts that share the same PayPal payout address as another suspended account. However, fraudsters are increasingly using shared ACH bank accounts (identified by Stripe fingerprints) across multiple accounts. When one account gets suspended, the related accounts using the same bank account slip through undetected, leading to more chargebacks and time spent hunting down fraudsters manually.

Solution

Extended the existing PayPal-based auto-suspension system to also handle Stripe bank account fingerprints:

When an admin suspends an account:

  • Find all other accounts using the same bank account fingerprint
  • Flag them for fraud and suspend them automatically
  • Add comments explaining why they were suspended and which account triggered it

When a new account sets up a bank account:

  • Check if the fingerprint matches any suspended account's bank account
  • Also check if the fingerprint is explicitly blocked
  • Flag the account for fraud if there's a match

When an account is marked compliant:

  • Related accounts with the same fingerprint are also marked compliant

I also addressed several edge cases that improve on the original PayPal implementation:

  • Checks ALL bank account fingerprints (not just the active one) since a fraudster might have multiple accounts
  • Preserves fraud history even if the suspended user deletes their bank account
  • Skips already suspended users to avoid duplicate processing
  • Added a database index on stripe_fingerprint for performance

Test Results

image

Checklist


AI Disclosure

Claude Opus 4.5 was used to assist with implementing this feature via Claude Code.

Comment thread app/modules/user/risk.rb
Comment on lines +144 to +146
def enable_accounts_with_same_stripe_fingerprint
fingerprints = bank_accounts.where.not(stripe_fingerprint: [nil, ""]).pluck(:stripe_fingerprint).uniq
return if fingerprints.empty?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same pattern as the suspend worker - checking all bank accounts (including deleted) to find all fingerprints this user has ever used.

Comment thread app/modules/user/risk.rb Outdated
Comment on lines +155 to +156
matching_fingerprint = (fingerprints & user.alive_bank_accounts.pluck(:stripe_fingerprint)).first
user.mark_compliant!(author_name: "enable_sellers_other_accounts", content: "Marked compliant automatically on #{Time.current.to_fs(:formatted_date_full_month)} as bank account fingerprint #{matching_fingerprint} is now unblocked")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Finding the specific matching fingerprint for an accurate admin comment.

bank_account.stripe_fingerprint = stripe_external_account.fingerprint
bank_account.save!

CheckPaymentAddressWorker.perform_async(bank_account.user_id)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This triggers the fraud check right after Stripe gives us the fingerprint. Mirrors how PayPal triggers the check in UpdatePayoutMethod when a payment address is set.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adding an index since we're now querying by fingerprint. Should help with performance when we have many bank accounts.


return if suspended_user.payment_address.blank?
suspend_users_with_same_payment_address(suspended_user)
suspend_users_with_same_stripe_fingerprint(suspended_user)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

After checking PayPal addresses, we now also check for matching Stripe fingerprints.

User.where(id: user_ids_with_same_fingerprint).find_each do |user|
next if user.suspended?

matching_fingerprint = (fingerprints & user.alive_bank_accounts.pluck(:stripe_fingerprint)).first

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

When the suspended user has multiple fingerprints, we find which specific one matched so the admin comment is accurate.

include Sidekiq::Job
sidekiq_options retry: 0, queue: :default

SUSPENDED_STATES = %w[suspended_for_tos_violation suspended_for_fraud].freeze

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Extracted this constant to avoid repetition and make it clearer what states we're checking for.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This constant would be better off being defined in the User::Risk module and invoked here

end

def stripe_fingerprint_matches_suspended_account?(user)
fingerprints = user.alive_bank_accounts.where.not(stripe_fingerprint: [nil, ""]).pluck(:stripe_fingerprint).uniq

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Checking all of the new user's fingerprints, not just their active bank account. A user might have multiple bank accounts.

Comment on lines +37 to +42
suspended_accounts_with_same_fingerprint = BankAccount
.joins(:user)
.where(stripe_fingerprint: fingerprints)
.where.not(user_id: user.id)
.where(users: { user_risk_state: SUSPENDED_STATES })

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not filtering by .alive here - we intentionally check against ALL bank accounts (including deleted) from suspended users. This preserves fraud history even if fraudsters delete their accounts.

.where.not(user_id: user.id)
.where(users: { user_risk_state: SUSPENDED_STATES })

blocked_fingerprints = fingerprints.any? { |fp| BlockedObject.find_active_object(fp).present? }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Also checking BlockedObject for explicitly blocked fingerprints, same pattern as the PayPal email check.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You can query MongoDB once instead of one time per fingerprint

Suggested change
blocked_fingerprints = fingerprints.any? { |fp| BlockedObject.find_active_object(fp).present? }
blocked_fingerprints = BlockedObject.find_active_objects(fingerprints)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you verify this does not evaluate immediately? It would be better to not send the query to MongoDB if suspended_accounts_with_same_fingerprint.exists? is already true

@yashranaway
yashranaway marked this pull request as ready for review January 10, 2026 07:36
Resolved conflicts in:
- app/sidekiq/suspend_accounts_with_payment_address_worker.rb
- app/modules/user/risk.rb

Combined Stripe fingerprint detection with skip_transition_callback fix.
- Keep upstream's schema version (2026_11_19_011937)
- Preserve stripe_fingerprint index on bank_accounts
- Use upstream's integer type for default_offer_code_id

@EmCousin EmCousin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The implementation looks sound and well tested, I added a few comments addressing performance. I will also run CI now. Can you address them, and fix failing tests if we find some. Thanks!

Comment thread app/modules/user/risk.rb Outdated
end

def enable_accounts_with_same_stripe_fingerprint
fingerprints = bank_accounts.where.not(stripe_fingerprint: [nil, ""]).pluck(:stripe_fingerprint).uniq

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You can uniq the fingerprints in the database rather than in memory:

Suggested change
fingerprints = bank_accounts.where.not(stripe_fingerprint: [nil, ""]).pluck(:stripe_fingerprint).uniq
fingerprints = bank_accounts.where.not(stripe_fingerprint: [nil, ""]).distinct.pluck(:stripe_fingerprint)

Comment thread app/modules/user/risk.rb Outdated
Comment on lines +152 to +156
user_ids_with_same_fingerprint = BankAccount.alive
.where(stripe_fingerprint: fingerprints)
.where.not(user_id: id)
.pluck(:user_id)
.uniq

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
user_ids_with_same_fingerprint = BankAccount.alive
.where(stripe_fingerprint: fingerprints)
.where.not(user_id: id)
.pluck(:user_id)
.uniq
user_ids_with_same_fingerprint = BankAccount.alive
.where(stripe_fingerprint: fingerprints)
.where.not(user_id: id)
.distinct
.pluck(:user_id)

include Sidekiq::Job
sidekiq_options retry: 0, queue: :default

SUSPENDED_STATES = %w[suspended_for_tos_violation suspended_for_fraud].freeze

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This constant would be better off being defined in the User::Risk module and invoked here

end

def stripe_fingerprint_matches_suspended_account?(user)
fingerprints = user.alive_bank_accounts.where.not(stripe_fingerprint: [nil, ""]).pluck(:stripe_fingerprint).uniq

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
fingerprints = user.alive_bank_accounts.where.not(stripe_fingerprint: [nil, ""]).pluck(:stripe_fingerprint).uniq
fingerprints = user.alive_bank_accounts.where.not(stripe_fingerprint: [nil, ""]).distinct.pluck(:stripe_fingerprint)

.where.not(user_id: user.id)
.where(users: { user_risk_state: SUSPENDED_STATES })

blocked_fingerprints = fingerprints.any? { |fp| BlockedObject.find_active_object(fp).present? }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You can query MongoDB once instead of one time per fingerprint

Suggested change
blocked_fingerprints = fingerprints.any? { |fp| BlockedObject.find_active_object(fp).present? }
blocked_fingerprints = BlockedObject.find_active_objects(fingerprints)

.where.not(user_id: user.id)
.where(users: { user_risk_state: SUSPENDED_STATES })

blocked_fingerprints = fingerprints.any? { |fp| BlockedObject.find_active_object(fp).present? }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you verify this does not evaluate immediately? It would be better to not send the query to MongoDB if suspended_accounts_with_same_fingerprint.exists? is already true

end

def suspend_users_with_same_stripe_fingerprint(suspended_user)
fingerprints = suspended_user.bank_accounts.where.not(stripe_fingerprint: [nil, ""]).pluck(:stripe_fingerprint).uniq

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
fingerprints = suspended_user.bank_accounts.where.not(stripe_fingerprint: [nil, ""]).pluck(:stripe_fingerprint).uniq
fingerprints = suspended_user.bank_accounts.where.not(stripe_fingerprint: [nil, ""]).distinct.pluck(:stripe_fingerprint)

Comment on lines +33 to +34
.pluck(:user_id)
.uniq

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
.pluck(:user_id)
.uniq
.distinct
.pluck(:user_id)

@yashranaway

Copy link
Copy Markdown
Contributor Author

Can you verify this does not evaluate immediately? It would be better to not send the query to MongoDB if suspended_accounts_with_same_fingerprint.exists? is already true

@EmCousin _a Fixed in fd488a6 The code now uses an early return. Also the CI failures are unrelated

@EmCousin
EmCousin merged commit 3e92cf5 into antiwork:main Jan 28, 2026
124 of 131 checks passed
@yashranaway
yashranaway deleted the feature/auto-suspend-shared-ach-accounts branch January 28, 2026 09:40
romitg2 pushed a commit to romitg2/gumroad that referenced this pull request Jan 29, 2026
…nts) (antiwork#2810)

Fixes : #1158

# Description

## Problem

We currently auto-suspend accounts that share the same PayPal payout
address as another suspended account. However, fraudsters are
increasingly using shared ACH bank accounts (identified by Stripe
fingerprints) across multiple accounts. When one account gets suspended,
the related accounts using the same bank account slip through
undetected, leading to more chargebacks and time spent hunting down
fraudsters manually.

## Solution

Extended the existing PayPal-based auto-suspension system to also handle
Stripe bank account fingerprints:

**When an admin suspends an account:**
- Find all other accounts using the same bank account fingerprint
- Flag them for fraud and suspend them automatically
- Add comments explaining why they were suspended and which account
triggered it

**When a new account sets up a bank account:**
- Check if the fingerprint matches any suspended account's bank account
- Also check if the fingerprint is explicitly blocked
- Flag the account for fraud if there's a match

**When an account is marked compliant:**
- Related accounts with the same fingerprint are also marked compliant

I also addressed several edge cases that improve on the original PayPal
implementation:
- Checks ALL bank account fingerprints (not just the active one) since a
fraudster might have multiple accounts
- Preserves fraud history even if the suspended user deletes their bank
account
- Skips already suspended users to avoid duplicate processing
- Added a database index on `stripe_fingerprint` for performance


# Test Results

<img width="781" height="237" alt="image"
src="https://github.com/user-attachments/assets/8ac8199d-83ca-4c8d-9ea8-41cd1c438c81"
/>


---

# Checklist

- [x] I have read the [contributing
guidelines](https://github.com/antiwork/gumroad/blob/main/CONTRIBUTING.md)
- [x] I have watched [Gumroad PR review
livestreams](https://www.youtube.com/@anti-work)
- [x] I have performed a self-review and left review comments on my PR
- [x] I have added/updated tests for my changes

---

# AI Disclosure

Claude Opus 4.5 was used to assist with implementing this feature via
Claude Code.

---------

Co-authored-by: Emmanuel Cousin <[email protected]>
alina-alyy pushed a commit to alina-alyy/gumroad that referenced this pull request Feb 7, 2026
…nts) (antiwork#2810)

Fixes : #1158

# Description

## Problem

We currently auto-suspend accounts that share the same PayPal payout
address as another suspended account. However, fraudsters are
increasingly using shared ACH bank accounts (identified by Stripe
fingerprints) across multiple accounts. When one account gets suspended,
the related accounts using the same bank account slip through
undetected, leading to more chargebacks and time spent hunting down
fraudsters manually.

## Solution

Extended the existing PayPal-based auto-suspension system to also handle
Stripe bank account fingerprints:

**When an admin suspends an account:**
- Find all other accounts using the same bank account fingerprint
- Flag them for fraud and suspend them automatically
- Add comments explaining why they were suspended and which account
triggered it

**When a new account sets up a bank account:**
- Check if the fingerprint matches any suspended account's bank account
- Also check if the fingerprint is explicitly blocked
- Flag the account for fraud if there's a match

**When an account is marked compliant:**
- Related accounts with the same fingerprint are also marked compliant

I also addressed several edge cases that improve on the original PayPal
implementation:
- Checks ALL bank account fingerprints (not just the active one) since a
fraudster might have multiple accounts
- Preserves fraud history even if the suspended user deletes their bank
account
- Skips already suspended users to avoid duplicate processing
- Added a database index on `stripe_fingerprint` for performance


# Test Results

<img width="781" height="237" alt="image"
src="https://github.com/user-attachments/assets/8ac8199d-83ca-4c8d-9ea8-41cd1c438c81"
/>


---

# Checklist

- [x] I have read the [contributing
guidelines](https://github.com/antiwork/gumroad/blob/main/CONTRIBUTING.md)
- [x] I have watched [Gumroad PR review
livestreams](https://www.youtube.com/@anti-work)
- [x] I have performed a self-review and left review comments on my PR
- [x] I have added/updated tests for my changes

---

# AI Disclosure

Claude Opus 4.5 was used to assist with implementing this feature via
Claude Code.

---------

Co-authored-by: Emmanuel Cousin <[email protected]>

This branch had an error being deployed

1 failed deployment
ci-protected — fd488a68 Deployed Jan 27, 2026 by yashranaway via CI Gate #6287
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Automatically suspend creators w/ shared ACH accounts

2 participants