Skip to content
Merged
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
104 changes: 68 additions & 36 deletions app/companies/api_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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():
Expand Down Expand Up @@ -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'),
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions app/models/company.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
71 changes: 71 additions & 0 deletions app/portfolio/templates/add_transaction.html
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ <h1>Add Transaction</h1>
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 }}">
Expand All @@ -111,6 +112,11 @@ <h1>Add Transaction</h1>
onclick="event.preventDefault(); openCompanyModal(handleCompanySelection);"
class="text-primary">Add a new company</a>
</div>
{# Kept after the link above: handleCompanySelection targets the
first .form-text in this block to flash its success message. #}
<div class="form-text" id="companyFilterHint" style="display: none;">
<i class="bi bi-funnel me-1"></i><span id="companyFilterHintText"></span>
</div>
</div>

<!-- Currency Selection (Auto-detected from ticker) -->
Expand Down Expand Up @@ -462,8 +468,14 @@ <h6 class="card-title">Transaction Types</h6>
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
Expand Down Expand Up @@ -587,8 +599,67 @@ <h6 class="card-title">Transaction Types</h6>
}

// 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
// <option> remains selectable.
companySelect.replaceChildren(...allowed);

// Drop a selection the new type does not permit
const stillValid = allowed.some(o => o.value === previousValue);
companySelect.value = stillValid ? previousValue : '';

const hint = document.getElementById('companyFilterHint');
const hintText = document.getElementById('companyFilterHintText');
const hiddenCount = allCompanyOptions.length - allowed.length;

if (hiddenCount > 0) {
const shown = allowed.length - 1; // exclude the placeholder
hintText.textContent = (transactionType === 'DIVIDEND')
? `Showing ${shown} ${shown === 1 ? 'company' : 'companies'} you have held — only these can pay a dividend.`
: `Showing ${shown} ${shown === 1 ? 'company' : 'companies'} you currently own — only these can be sold.`;
hint.style.display = 'block';
} else {
hint.style.display = 'none';
}

// Let currency/position handlers react to a cleared selection
if (!stillValid && previousValue) {
companySelect.dispatchEvent(new Event('change'));
}
}

function updateLabels() {
const transactionType = document.querySelector('input[name="type"]:checked').value;
filterCompaniesByType();
const quantityLabel = document.getElementById('quantityLabel');
const priceLabel = document.getElementById('priceLabel');
const expectationsSection = document.getElementById('buy_expectations_section');
Expand Down
83 changes: 48 additions & 35 deletions app/portfolio/transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,52 @@ def force_resync():

return redirect(url_for('portfolio.dashboard'))

def _add_transaction_context():
"""
Build the template context for the add-transaction form.

Shared by the initial GET and the re-render that follows a validation
warning, so the form behaves identically on both paths. Previously the
re-render passed only `companies`, which silently disabled the
add-to-position UI after a warning.
"""
companies = Company.query.filter_by(
user_id=current_user.id
).order_by(Company.name).all()

# A PortfolioPosition row survives a full exit, so its mere existence means
# the user held the company at some point. Only the shares differ.
existing_positions = {}
held_company_ids = set()
positions = PortfolioPosition.query.filter_by(user_id=current_user.id).all()
for pos in positions:
held_company_ids.add(pos.company_id)
if pos.total_shares > 0:
existing_positions[pos.company_id] = {
'shares': pos.total_shares,
'avg_cost': float(pos.average_cost_basis) if pos.average_cost_basis else 0,
'ticker': pos.company.ticker_symbol
}

# Check which companies have research
companies_with_research = set()
research_projects = ResearchProject.query.filter_by(user_id=current_user.id).all()
for rp in research_projects:
companies_with_research.add(rp.company_id)

return {
'companies': companies,
'existing_positions': existing_positions,
# Dividends need a company held at some point; sells need shares now.
'held_company_ids': held_company_ids,
'owned_company_ids': set(existing_positions.keys()),
'companies_with_research': companies_with_research,
'preselected_company_id': request.args.get('company_id', type=int),
'user_currency': current_user.base_currency,
'currency_symbol': CurrencyService.get_currency_symbol(current_user.base_currency),
}


@portfolio_bp.route('/transaction/new', methods=['GET', 'POST'])
@portfolio_bp.route('/transaction/add', methods=['GET', 'POST'])
@login_required
Expand Down Expand Up @@ -281,52 +327,19 @@ def add_transaction():
flash(result.error, 'error')
# Re-render form with warnings if validation failed
if result.warnings:
companies = Company.query.filter_by(user_id=current_user.id).order_by(Company.name).all()
return render_template('add_transaction.html',
companies=companies,
warnings=result.warnings,
show_warning=True,
form_data=request.form,
user_currency=current_user.base_currency,
currency_symbol=CurrencyService.get_currency_symbol(current_user.base_currency))
**_add_transaction_context())
return redirect(url_for('portfolio.add_transaction'))

# GET request - show form
companies = Company.query.filter_by(user_id=current_user.id).order_by(Company.name).all()

# Get existing positions for the user
existing_positions = {}
positions = PortfolioPosition.query.filter_by(user_id=current_user.id).all()
for pos in positions:
if pos.total_shares > 0:
existing_positions[pos.company_id] = {
'shares': pos.total_shares,
'avg_cost': float(pos.average_cost_basis) if pos.average_cost_basis else 0,
'ticker': pos.company.ticker_symbol
}

# Check which companies have research
companies_with_research = set()
research_projects = ResearchProject.query.filter_by(user_id=current_user.id).all()
for rp in research_projects:
companies_with_research.add(rp.company_id)

# Get company_id from query params if provided
preselected_company_id = request.args.get('company_id', type=int)

user_currency = current_user.base_currency
currency_symbol = CurrencyService.get_currency_symbol(user_currency)

return render_template('add_transaction.html',
companies=companies,
warnings=warnings,
show_warning=False,
form_data=None,
existing_positions=existing_positions,
companies_with_research=companies_with_research,
preselected_company_id=preselected_company_id,
user_currency=user_currency,
currency_symbol=currency_symbol)
**_add_transaction_context())

@portfolio_bp.route('/transaction/<int:transaction_id>/edit', methods=['GET', 'POST'])
@login_required
Expand Down
Loading
Loading