From 58a2198a92adfa5ac28366cad84bad9b1c63dc77 Mon Sep 17 00:00:00 2001 From: warlock20 Date: Tue, 21 Jul 2026 18:22:45 +0200 Subject: [PATCH] feat: Implement unique company ticker constraint per user and enhance company identity handling --- app/companies/api_routes.py | 104 +++++++++----- app/models/company.py | 6 + app/portfolio/templates/add_transaction.html | 71 ++++++++++ app/portfolio/transactions.py | 83 ++++++----- .../financial_data/financial_data_service.py | 43 +++++- app/services/financial_data/providers/base.py | 7 + .../financial_data/providers/yahoo_finance.py | 4 + app/services/transaction_service.py | 14 ++ app/utils/company_identity.py | 133 ++++++++++++++++++ .../unique_company_ticker_per_user.py | 71 ++++++++++ 10 files changed, 462 insertions(+), 74 deletions(-) create mode 100644 app/utils/company_identity.py create mode 100644 migrations/versions/unique_company_ticker_per_user.py diff --git a/app/companies/api_routes.py b/app/companies/api_routes.py index 63d549d..64d53a1 100644 --- a/app/companies/api_routes.py +++ b/app/companies/api_routes.py @@ -24,6 +24,7 @@ from app.services.financial_data import FinancialDataService from app.companies import companies_bp from app.utils.ticker_validator import TickerValidator +from app.utils.company_identity import company_identity_key from app.services.currency_service import CurrencyService from app.utils.response_utils import json_error, json_not_found from app.utils.time_utils import now_utc @@ -40,6 +41,18 @@ def get_financial_service(): _financial_service = FinancialDataService() return _financial_service +def _serialize_user_company(company): + """Shape a Company the user owns for the search response.""" + return { + 'id': company.id, + 'name': company.name, + 'ticker_symbol': company.ticker_symbol, + 'industry': company.industry, + 'sector': company.sector.display_name if company.sector else None, + 'source': 'existing' + } + + @companies_bp.route('/api/companies/search') @login_required def api_search_companies(): @@ -70,34 +83,56 @@ def api_search_companies(): db.or_(*search_conditions) ).order_by(Company.name).limit(10).all() - user_company_data = [] - for company in user_companies: - user_company_data.append({ - 'id': company.id, - 'name': company.name, - 'ticker_symbol': company.ticker_symbol, - 'industry': company.industry, - 'sector': company.sector.display_name if company.sector else None, - 'source': 'existing' - }) + user_company_data = [_serialize_user_company(c) for c in user_companies] + listed_company_ids = {c['id'] for c in user_company_data} + + # Identity of every company the user already owns. The ticker saved on the + # company is the user's own choice of exchange, so a provider listing of the + # same company under any other ticker is a duplicate, not a new company. + owned_rows = db.session.query( + Company.id, Company.name, Company.ticker_symbol + ).filter(Company.user_id == current_user.id).all() + + owned_company_ids = {} + for company_id, name, ticker in owned_rows: + owned_company_ids.setdefault(company_identity_key(name, ticker), company_id) + + def claim_owned_company(identity): + """ + Resolve a provider listing against the user's own companies. + + Returns True when the company is already owned, in which case it is + surfaced under the user's saved ticker rather than offered again. A + user who saved Microsoft as MSF.F and then searches "MSFT" must still + see their own holding, otherwise the UI offers to create a duplicate. + """ + company_id = owned_company_ids.get(identity) + if company_id is None: + return False + + if company_id not in listed_company_ids: + listed_company_ids.add(company_id) + user_company_data.append( + _serialize_user_company(db.session.get(Company, company_id)) + ) + return True # Try financial data service lookup - both by ticker AND by company name yahoo_suggestions = [] + suggested_identities = set() service = get_financial_service() # 1. If query is a valid ticker, look it up directly if normalized_ticker: try: - # Check if this ticker already exists for the user - existing = Company.query.filter_by( - ticker_symbol=normalized_ticker, - user_id=current_user.id - ).first() + info = service.get_ticker_info(normalized_ticker) - if not existing: - info = service.get_ticker_info(normalized_ticker) + if info and info.get('name'): + identity = company_identity_key(info.get('name'), normalized_ticker) - if info and info.get('name'): + # Skip if the user already owns this company, under any ticker + if not claim_owned_company(identity): + suggested_identities.add(identity) yahoo_suggestions.append({ 'ticker_symbol': normalized_ticker, 'name': info.get('name'), @@ -113,29 +148,26 @@ def api_search_companies(): # 2. Search by company name (if not a ticker or no results from ticker search) if len(query) >= 3 and len(yahoo_suggestions) == 0: try: - search_results = service.search_companies(query, max_results=5) + # The service already collapses cross-listings to one row per company + search_results = service.search_companies(query, max_results=3) - for result in search_results[:3]: + for result in search_results: ticker_symbol = result.get('ticker_symbol') + name = result.get('name') or ticker_symbol - if not ticker_symbol: + identity = company_identity_key(name, ticker_symbol) + if identity in suggested_identities or claim_owned_company(identity): continue - # Skip if user already has this company - existing = Company.query.filter_by( - ticker_symbol=ticker_symbol, - user_id=current_user.id - ).first() - - if not existing: - yahoo_suggestions.append({ - 'ticker_symbol': ticker_symbol, - 'name': result.get('name') or ticker_symbol, - 'industry': result.get('industry') or '', - 'sector': result.get('sector') or '', - 'summary': '', - 'source': 'financial_data_service' - }) + suggested_identities.add(identity) + yahoo_suggestions.append({ + 'ticker_symbol': ticker_symbol, + 'name': name, + 'industry': result.get('industry') or '', + 'sector': result.get('sector') or '', + 'summary': '', + 'source': 'financial_data_service' + }) except Exception as e: logger.debug(f'Company search failed: {e}') pass diff --git a/app/models/company.py b/app/models/company.py index 93551cf..2a41eb3 100644 --- a/app/models/company.py +++ b/app/models/company.py @@ -37,6 +37,12 @@ class Company(db.Model): journey_notes = db.Column(db.Text, nullable=True) journey_notes_updated_at = db.Column(db.DateTime, nullable=True) + __table_args__ = ( + # One company per ticker per user. Application-level guards existed on + # every creation path but duplicates still got through. + db.UniqueConstraint('user_id', 'ticker_symbol', name='uq_company_user_ticker'), + ) + # Relationships sector = db.relationship("Sector", backref="companies") diff --git a/app/portfolio/templates/add_transaction.html b/app/portfolio/templates/add_transaction.html index 81eff8e..d140205 100644 --- a/app/portfolio/templates/add_transaction.html +++ b/app/portfolio/templates/add_transaction.html @@ -97,6 +97,7 @@

Add Transaction

endif %} {% if form_data and form_data.company_id|int==company.id %}selected{% endif %} data-has-position="{% if company.id in existing_positions %}true{% else %}false{% endif %}" + data-ever-held="{% if company.id in held_company_ids %}true{% else %}false{% endif %}" data-position-shares="{% if company.id in existing_positions %}{{ existing_positions[company.id].shares }}{% endif %}" data-position-cost="{% if company.id in existing_positions %}{{ existing_positions[company.id].avg_cost }}{% endif %}" data-ticker="{{ company.ticker_symbol }}"> @@ -111,6 +112,11 @@

Add Transaction

onclick="event.preventDefault(); openCompanyModal(handleCompanySelection);" class="text-primary">Add a new company + {# Kept after the link above: handleCompanySelection targets the + first .form-text in this block to flash its success message. #} + @@ -462,8 +468,14 @@
Transaction Types
option.text = `${company.ticker_symbol} - ${company.name}`; option.setAttribute('data-ticker', company.ticker_symbol); option.setAttribute('data-has-position', 'false'); + option.setAttribute('data-ever-held', 'false'); option.selected = true; companySelect.add(option); + + // Keep the filter snapshot in sync, or the next type change drops it + if (allCompanyOptions !== null) { + allCompanyOptions.push(option); + } } // Trigger change event to update currency and other related fields @@ -587,8 +599,67 @@
Transaction Types
} // Update labels and field visibility based on transaction type + // ============================================================ + // COMPANY LIST FILTERING BY TRANSACTION TYPE + // ============================================================ + // Only a company you have held can have paid you a dividend, and only one + // you currently own can be sold. Watchlist companies are hidden for those + // types so the list shows what is actually actionable. + + let allCompanyOptions = null; + + function companyAllowedForType(option, transactionType) { + if (!option.value) return true; // keep the "Select a company..." placeholder + if (transactionType === 'DIVIDEND') return option.dataset.everHeld === 'true'; + if (transactionType === 'SELL') return option.dataset.hasPosition === 'true'; + return true; + } + + function filterCompaniesByType() { + const companySelect = document.getElementById('company_id'); + const checkedType = document.querySelector('input[name="type"]:checked'); + if (!companySelect || !checkedType) return; + + // Snapshot the full list once, before any filtering removes options + if (allCompanyOptions === null) { + allCompanyOptions = Array.from(companySelect.options); + } + + const transactionType = checkedType.value; + const previousValue = companySelect.value; + const allowed = allCompanyOptions.filter(o => companyAllowedForType(o, transactionType)); + + // Rebuild rather than hide: browsers disagree on whether a hidden + //