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
+ //