import logging from datetime import date as date_cls, datetime from flask import (Blueprint, render_template, redirect, url_for, flash, request, session, current_app) from flask_login import login_required from app.extensions import db from app.models.account import Account from app.models.schwab_connection import SchwabConnection, SchwabAccount from app.services.schwab_service import ( get_auth_url, exchange_code, _apply_token_data, get_accounts, sync_preview, import_transactions, ACCOUNT_TYPE_MAP, ) schwab_bp = Blueprint('schwab', __name__, url_prefix='/schwab') log = logging.getLogger(__name__) def _active_connection(): return SchwabConnection.query.filter_by(is_active=True).first() # ── Connect ─────────────────────────────────────────────────────────────────── @schwab_bp.route('/connect') @login_required def connect(): if not current_app.config.get('SCHWAB_CLIENT_ID'): flash('SCHWAB_CLIENT_ID is not set in .env — add it and restart.', 'danger') return redirect(url_for('schwab.index')) auth_url, state = get_auth_url() session['schwab_oauth_state'] = state return redirect(auth_url) @schwab_bp.route('/callback') @login_required def callback(): error = request.args.get('error') if error: flash(f'Schwab connection cancelled: {error}', 'warning') return redirect(url_for('schwab.index')) code = request.args.get('code', '') state = request.args.get('state', '') if not code: flash('No authorization code received from Schwab.', 'danger') return redirect(url_for('schwab.index')) if state != session.pop('schwab_oauth_state', None): flash('OAuth state mismatch — possible CSRF. Please try again.', 'danger') return redirect(url_for('schwab.index')) try: token_data = exchange_code(code) except Exception as e: log.error('[schwab] token exchange failed: %s', e, exc_info=True) flash(f'Failed to connect to Schwab: {e}', 'danger') return redirect(url_for('schwab.index')) # Deactivate any previous connection SchwabConnection.query.filter_by(is_active=True).update({'is_active': False}) conn = SchwabConnection( access_token='', refresh_token='', token_expires_at=datetime.utcnow(), ) _apply_token_data(conn, token_data) db.session.add(conn) db.session.flush() # Fetch accounts and store them try: raw_accounts = get_accounts(conn) except Exception as e: log.error('[schwab] get_accounts failed: %s', e, exc_info=True) db.session.rollback() flash(f'Connected but failed to fetch accounts: {e}', 'danger') return redirect(url_for('schwab.index')) for ra in raw_accounts: sec = ra.get('securitiesAccount', {}) acct_hash = sec.get('accountNumber', '') if not acct_hash: continue existing = SchwabAccount.query.filter_by(account_hash=acct_hash).first() if not existing: masked = '…' + acct_hash[-4:] if len(acct_hash) >= 4 else acct_hash db.session.add(SchwabAccount( connection=conn, account_hash=acct_hash, account_number_display=masked, account_type=sec.get('type', 'CASH'), account_name=f'Schwab {sec.get("type","Account")} {masked}', )) db.session.commit() flash('Schwab connected successfully. Map your accounts to get started.', 'success') return redirect(url_for('schwab.map_accounts')) # ── Index ───────────────────────────────────────────────────────────────────── @schwab_bp.route('/') @login_required def index(): connection = _active_connection() accounts = connection.accounts.filter_by(is_active=True).all() if connection else [] return render_template('schwab/index.html', connection=connection, accounts=accounts, schwab_configured=bool(current_app.config.get('SCHWAB_CLIENT_ID'))) # ── Account mapping ─────────────────────────────────────────────────────────── @schwab_bp.route('/map', methods=['GET', 'POST']) @login_required def map_accounts(): connection = _active_connection() if not connection: flash('No active Schwab connection.', 'warning') return redirect(url_for('schwab.index')) schwab_accounts = connection.accounts.filter_by(is_active=True).all() pfm_accounts = Account.query.filter_by(is_active=True).order_by(Account.name).all() if request.method == 'POST': for sa in schwab_accounts: val = request.form.get(f'pfm_account_{sa.id}', '') if val == 'new': pfm_type = ACCOUNT_TYPE_MAP.get(sa.account_type, 'other') new_acct = Account( name=sa.account_name, account_type=pfm_type, color='#4F81C7', icon='bi-bank', balance=0, ) db.session.add(new_acct) db.session.flush() sa.pfm_account_id = new_acct.id elif val.isdigit(): acct_id = int(val) if Account.query.filter_by(id=acct_id, is_active=True).first(): sa.pfm_account_id = acct_id db.session.commit() mapped = [sa for sa in schwab_accounts if sa.pfm_account_id] if not mapped: flash('Select at least one account to map.', 'warning') return redirect(url_for('schwab.map_accounts')) flash(f'{len(mapped)} account(s) mapped. Ready to sync.', 'success') return redirect(url_for('schwab.index')) return render_template('schwab/map_accounts.html', connection=connection, schwab_accounts=schwab_accounts, pfm_accounts=pfm_accounts) # ── Sync: preview ───────────────────────────────────────────────────────────── @schwab_bp.route('/sync/') @login_required def sync_preview_view(schwab_account_id): sa = db.get_or_404(SchwabAccount, schwab_account_id) if not sa.pfm_account_id: flash('Map this account to a PFM account first.', 'warning') return redirect(url_for('schwab.map_accounts')) try: preview = sync_preview(sa) except Exception as e: log.error('[schwab] sync_preview failed for account id=%s: %s', schwab_account_id, e, exc_info=True) flash(f'Sync failed: {e}', 'danger') return redirect(url_for('schwab.index')) from app.models.category import Category cat_map = {c.id: c.name for c in Category.query.filter_by(is_active=True).all()} session['schwab_preview'] = [ { 'schwab_id': p['schwab_id'], 'date': p['date'].isoformat(), 'transaction_type': p['transaction_type'], 'amount': float(p['amount']), 'description': p['description'], 'account_id': p['account_id'], 'category_id': p['category_id'], 'notes': p['notes'], 'schwab_type': p['schwab_type'], } for p in preview ] session['schwab_account_id'] = schwab_account_id return render_template('schwab/preview.html', sa=sa, preview=preview, count=len(preview), cat_map=cat_map) # ── Sync: confirm ───────────────────────────────────────────────────────────── @schwab_bp.route('/sync/confirm', methods=['POST']) @login_required def sync_confirm(): raw = session.pop('schwab_preview', []) sa_id = session.pop('schwab_account_id', None) if not raw or not sa_id: flash('No pending import. Please sync again.', 'warning') return redirect(url_for('schwab.index')) sa = db.get_or_404(SchwabAccount, sa_id) selected_ids = set(request.form.getlist('selected')) parsed = [] for r in raw: if selected_ids and r['schwab_id'] not in selected_ids: continue r['date'] = date_cls.fromisoformat(r['date']) override = request.form.get(f'type_{r["schwab_id"]}') if override in ('income', 'expense'): r['transaction_type'] = override parsed.append(r) if not parsed: flash('No transactions selected.', 'warning') return redirect(url_for('schwab.index')) imported, skipped = import_transactions(parsed, sa) flash(f'Imported {imported} transaction(s) from {sa.account_name}. ' f'Skipped {skipped} duplicate(s).', 'success') return redirect(url_for('transactions.index')) # ── Full resync ─────────────────────────────────────────────────────────────── @schwab_bp.route('/resync/', methods=['POST']) @login_required def full_resync(schwab_account_id): sa = db.get_or_404(SchwabAccount, schwab_account_id) sa.last_sync_date = None sa.last_schwab_txn_id = None db.session.commit() flash(f'{sa.account_name}: reset to full resync. ' f'Next sync fetches 90 days — duplicates are skipped automatically.', 'info') return redirect(url_for('schwab.index')) # ── Disconnect ──────────────────────────────────────────────────────────────── @schwab_bp.route('/disconnect', methods=['POST']) @login_required def disconnect(): connection = _active_connection() if connection: connection.is_active = False for sa in connection.accounts: sa.is_active = False db.session.commit() flash('Disconnected from Schwab. Your imported transactions are kept.', 'info') return redirect(url_for('schwab.index'))