import argparse import json import stripe import logging from dataclasses import dataclass, field, asdict import asyncio import threading import os from dotenv import load_dotenv from datetime import datetime from snowflake.snowpark import Session logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" ) logging.getLogger("stripe").setLevel(logging.WARNING) SEARCHED_EMAILS = set() EMAIL_LOCK = threading.Lock() load_dotenv() STRIPE_API_KEY = os.getenv("STRIPE_API_KEY") stripe.api_key = STRIPE_API_KEY # Connection parameters from environment variables SNOWFLAKE_CONNECTION_PARAMETERS = { "account": "fu90569.us-east-2.aws", "user": os.getenv("SNOWFLAKE_USERNAME"), "password": os.getenv("SNOWFLAKE_PASSWORD"), "role": "ACCOUNTADMIN", "warehouse": "SUNO_PROD_X_SMAL", "database": "SUNO_PROD", "schema": "PROD", # Default to PUBLIC if not specified } @dataclass class BillingAddress: city: str country: str line1: str line2: str postal_code: str state: str @dataclass class StripeCustomer: id: str email: str billing_address: BillingAddress | None = None card_fingerprints: list[str] = field(default_factory=list) related_customer_ids: list[str] = field(default_factory=list) def __hash__(self): return hash(self.id) # Only use the unique identifier for hashing def __eq__(self, other): if not isinstance(other, StripeCustomer): return False return self.id == other.id # Two customers are equal if they have the same ID async def find_related_customers_by_payment_details( seed_customer_id, ) -> tuple[set[str], set[str]]: # returns a tuple of (related_customer_ids, payment_fingerprints) related_customers = set() # Use asyncio.to_thread to run blocking Stripe operations in a separate thread charges = await asyncio.to_thread(stripe.Charge.list, customer=seed_customer_id) payment_fingerprints = set() for charge in charges.data: if hasattr(charge, "payment_method_details"): source_type = charge.payment_method_details.type if source_type != "card": # only handle card payments for now continue payment_details = getattr(charge.payment_method_details, source_type, None) if payment_details: fingerprint = getattr(payment_details, "fingerprint", None) payment_fingerprints.add(fingerprint) query_components = [] for fp in payment_fingerprints: _query = f"payment_method_details.card.fingerprint:'{fp}'" query_components.append(_query) if not query_components: return (related_customers, payment_fingerprints) full_query = " OR ".join(query_components) try: similar_charges = await asyncio.to_thread( stripe.Charge.search, query=full_query ) customers_for_fingerprint = {charge.customer for charge in similar_charges.data} related_customers.update( cust for cust in customers_for_fingerprint if cust != seed_customer_id ) except stripe.error.InvalidRequestError as e: logging.error(f"Search query failed: {e}") return (related_customers, payment_fingerprints) # async def find_related_customers_by_email(seed_customer_id): async def find_related_customers_by_email(customer: stripe.Customer): related_customers = set() # Get the seed customer's email # customer = await asyncio.to_thread(stripe.Customer.retrieve, seed_customer_id) if ( not hasattr(customer, "email") or not customer.get("email") or customer.get("email") == "" ): logging.info(f"No email found for customer {customer.id}") return related_customers email = customer["email"] with EMAIL_LOCK: if email in SEARCHED_EMAILS: logging.info(f"Already searched email {email}") return related_customers patterns = [ f'email:"{email}"', # Exact match # f'email:"{email_base}*@{domain}"', # Variations with same base and domain # f'email:"*{email_base}*@{domain}"', # More variations including prefixes ] # Search for each pattern for pattern in patterns: try: results = await asyncio.to_thread(stripe.Customer.search, query=pattern) for found_customer in results.auto_paging_iter(): if found_customer.id != customer.id: related_customers.add(found_customer.id) logging.info( f"Found related customer through email pattern '{pattern}': " f"{found_customer.id} ({found_customer.email})" ) except stripe.error.InvalidRequestError as e: logging.error(f"Search query failed for pattern '{pattern}': {e}") continue with EMAIL_LOCK: SEARCHED_EMAILS.add(email) return related_customers async def get_customer_info_and_find_related(customer_id) -> StripeCustomer: customer = await asyncio.to_thread( stripe.Customer.retrieve, customer_id, expand=["sources"] ) # run payment and email searches in parallel ( (related_customer_ids_by_payments, payment_fingerprints), related_by_email, ) = await asyncio.gather( find_related_customers_by_payment_details(customer_id), find_related_customers_by_email(customer), ) # get billing address billing_address = customer.get("address") if billing_address: billing_address = BillingAddress( city=billing_address.get("city"), country=billing_address.get("country"), line1=billing_address.get("line1"), line2=billing_address.get("line2"), postal_code=billing_address.get("postal_code"), state=billing_address.get("state"), ) # logging.info(f"Billing address: {billing_address}") # else: # logging.info("No billing address found") return StripeCustomer( id=customer.id, email=customer.get("email", ""), billing_address=billing_address, card_fingerprints=list(payment_fingerprints), related_customer_ids=list(related_customer_ids_by_payments | related_by_email), ) def load_file(file_path): with open(file_path, "r") as file: return [s.strip() for s in file.readlines()] def create_session(): try: # Create Snowflake session session = Session.builder.configs(SNOWFLAKE_CONNECTION_PARAMETERS).create() print("Successfully connected to Snowflake!") session.sql("USE WAREHOUSE SUNO_PROD_X_SMAL").collect() return session except Exception as e: print(f"Error connecting to Snowflake: {str(e)}") raise def get_user_ids_by_stripe_customer_id(session, stripe_customer_ids): customer_id_strs = [f"'{customer_id}'" for customer_id in stripe_customer_ids] query = f""" SELECT user_id FROM suno_prod.prod.rds_discord_info WHERE stripe_customer_id IN ({", ".join(customer_id_strs)}) AND stripe_customer_id IS NOT NULL """ results = session.sql(query).collect() # print(results) return [row["USER_ID"] for row in results] def fetch_suno_user_ids_from_snowflake(stripe_customer_ids): logging.info( f"Fetching user ids for {len(stripe_customer_ids)} stripe customer ids from snowflake" ) session = create_session() user_ids = get_user_ids_by_stripe_customer_id(session, stripe_customer_ids) logging.info(f"Found {len(user_ids)} user ids in snowflake") return user_ids async def async_main(): parser = argparse.ArgumentParser(description="Find related Stripe customers") parser.add_argument( "--customer-ids", dest="customer_ids", required=False, nargs="+", help="One or more root customer IDs to start the search from", ) parser.add_argument( "--customer-ids-file", dest="customer_ids_file", required=False, help="File containing customer IDs to process", ) parser.add_argument( "--output-prefix", dest="output_prefix", required=True, help="Prefix for the output files", ) args = parser.parse_args() if not args.customer_ids and not args.customer_ids_file: print("You must provide either --customer-ids or --customer-ids-file") return stripe_customer_ids = set() if args.customer_ids_file: stripe_customer_ids.update(load_file(args.customer_ids_file)) if args.customer_ids: stripe_customer_ids.update(args.customer_ids) customers_to_process = set(stripe_customer_ids) processed_customers = set() all_related_customers = set(stripe_customer_ids) BATCH_SIZE = 10 processed_customer_objects: set[StripeCustomer] = set() while customers_to_process: logging.info(f"Processing {len(customers_to_process)} customers") logging.info(f"Processed {len(processed_customers)} customers") logging.info( f"Found total related customers {len(all_related_customers)} customers" ) batch = set() for _ in range(min(BATCH_SIZE, len(customers_to_process))): if customers_to_process: customer_id = customers_to_process.pop() if customer_id not in processed_customers: batch.add(customer_id) if not batch: continue # Process customers in parallel results = await asyncio.gather( *(get_customer_info_and_find_related(customer) for customer in batch) ) # Safely update sets with results for customer_id, customer in zip(batch, results): # Mark current customer as processed processed_customers.add(customer_id) processed_customer_objects.add(customer) # add to global related set all_related_customers.update(customer.related_customer_ids) all_related_customers.add(customer.id) # probably redundant # Add newly found customers to processing queue new_to_process = { c for c in customer.related_customer_ids if c not in processed_customers and c not in customers_to_process } customers_to_process.update(new_to_process) logging.info( f"Found {len(new_to_process)} new related customers for {customer.id} {customer.email}" ) logging.info(f"Total processed customers: {len(processed_customers)}") logging.info(f"Total related customers found: {len(all_related_customers)}") logging.info(f"All related customer_ids: {all_related_customers}") output_prefix = args.output_prefix current_time = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") output_prefix_with_time = f"{output_prefix}_{current_time}_" customer_id_filename = f"{output_prefix_with_time}stripe_customer_ids.txt" email_filename = f"{output_prefix_with_time}stripe_customer_emails.txt" fingerprint_filename = f"{output_prefix_with_time}stripe_card_fingerprints.txt" stripe_customer_json_filename = ( f"{output_prefix_with_time}stripe_customer_objects.jsonl" ) user_id_filename = f"{output_prefix_with_time}suno_user_ids.txt" all_customer_fingerprints = set() distinct_emails = set() logging.info(f"Writing {len(processed_customer_objects)} customers to files") logging.info( f"Writing {len(processed_customer_objects)} customer ids to {customer_id_filename}" ) with open(customer_id_filename, "w") as customer_ids_file: for customer in processed_customer_objects: all_customer_fingerprints.update(customer.card_fingerprints) if customer.email: distinct_emails.add(customer.email) customer_ids_file.write(customer.id + "\n") logging.info(f"Writing {len(distinct_emails)} distinct emails to {email_filename}") with open(email_filename, "w") as emails_file: for email in distinct_emails: emails_file.write(email + "\n") logging.info( f"Writing {len(all_customer_fingerprints)} card fingerprints to {fingerprint_filename}" ) with open(fingerprint_filename, "w") as fingerprints_file: for fingerprint in all_customer_fingerprints: fingerprints_file.write(fingerprint + "\n") logging.info( f"Writing {len(processed_customer_objects)} stripe customers JSON objects to {stripe_customer_json_filename}" ) with open(stripe_customer_json_filename, "w") as f: for customer in processed_customer_objects: f.write(json.dumps(asdict(customer)) + "\n") suno_user_ids = fetch_suno_user_ids_from_snowflake(all_related_customers) logging.info(f"Writing {len(suno_user_ids)} suno user ids to {user_id_filename}") with open(user_id_filename, "w") as f: for user_id in suno_user_ids: f.write(str(user_id) + "\n") logging.info("All done -- happy hunting!") def main(): asyncio.run(async_main()) if __name__ == "__main__": """ See README.md for more detailed instructions. python stripe_find_related_accounts.py --output-prefix --customer-ids Output prefix is used to name the output files, datetime is also added for clarity. You can also pass in a file of customer ids to process: python stripe_find_related_accounts.py --output-prefix --customer-ids-file """ main()