From 249300b871c24c1f39fd50bc711459ef187f69fa Mon Sep 17 00:00:00 2001 From: NguyenND Date: Tue, 2 Jun 2026 17:05:44 -0400 Subject: [PATCH] 06/02 Integrate Schwab --- app/__init__.py | 3 + app/config.py | 6 + app/models/schwab_connection.py | 48 ++++ app/routes/schwab.py | 274 +++++++++++++++++++++ app/services/schwab_service.py | 315 +++++++++++++++++++++++++ app/templates/base.html | 14 +- app/templates/schwab/index.html | 144 +++++++++++ app/templates/schwab/map_accounts.html | 52 ++++ app/templates/schwab/preview.html | 139 +++++++++++ app/templates/settings/index.html | 11 +- 10 files changed, 1004 insertions(+), 2 deletions(-) create mode 100644 app/models/schwab_connection.py create mode 100644 app/routes/schwab.py create mode 100644 app/services/schwab_service.py create mode 100644 app/templates/schwab/index.html create mode 100644 app/templates/schwab/map_accounts.html create mode 100644 app/templates/schwab/preview.html diff --git a/app/__init__.py b/app/__init__.py index 77030dd..748cc8c 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -89,6 +89,7 @@ def create_app(config_name=None): from app.routes.reports import reports_bp from app.routes.settings import settings_bp from app.routes.teller import teller_bp + from app.routes.schwab import schwab_bp from app.routes.logs import logs_bp from app.routes.bank_import import bank_import_bp @@ -104,6 +105,7 @@ def create_app(config_name=None): app.register_blueprint(reports_bp) app.register_blueprint(settings_bp) app.register_blueprint(teller_bp) + app.register_blueprint(schwab_bp) app.register_blueprint(logs_bp) app.register_blueprint(bank_import_bp) @@ -115,6 +117,7 @@ def create_app(config_name=None): AiInsight, FxRate ) from app.models.teller_enrollment import TellerEnrollment, TellerAccount + from app.models.schwab_connection import SchwabConnection, SchwabAccount @app.after_request def security_headers(response): diff --git a/app/config.py b/app/config.py index c7df588..b9204b7 100644 --- a/app/config.py +++ b/app/config.py @@ -30,6 +30,12 @@ class Config: TELLER_KEY_PATH = os.environ.get('TELLER_KEY_PATH', '/home/pfm/teller/private_key.pem') TELLER_WEBHOOK_SECRET = os.environ.get('TELLER_WEBHOOK_SECRET', '') + # Schwab Developer API (OAuth 2.0) + SCHWAB_CLIENT_ID = os.environ.get('SCHWAB_CLIENT_ID', '') + SCHWAB_CLIENT_SECRET = os.environ.get('SCHWAB_CLIENT_SECRET', '') + SCHWAB_REDIRECT_URI = os.environ.get('SCHWAB_REDIRECT_URI', + 'https://pfm.ngodanguyen.tech/schwab/callback') + # Budget alert emails (optional — all four must be set to enable) SMTP_HOST = os.environ.get('SMTP_HOST', '') SMTP_PORT = int(os.environ.get('SMTP_PORT', 587)) diff --git a/app/models/schwab_connection.py b/app/models/schwab_connection.py new file mode 100644 index 0000000..53c1cfb --- /dev/null +++ b/app/models/schwab_connection.py @@ -0,0 +1,48 @@ +from app.extensions import db +from datetime import datetime + + +class SchwabConnection(db.Model): + """OAuth connection to Schwab. One per user (single-user app).""" + __tablename__ = 'schwab_connections' + + id = db.Column(db.Integer, primary_key=True) + access_token = db.Column(db.Text, nullable=False) + refresh_token = db.Column(db.Text, nullable=False) + token_expires_at = db.Column(db.DateTime, nullable=False) # UTC + is_active = db.Column(db.Boolean, default=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + last_synced_at = db.Column(db.DateTime, nullable=True) + + accounts = db.relationship('SchwabAccount', back_populates='connection', + cascade='all, delete-orphan', lazy='dynamic') + + @property + def token_is_expired(self): + return datetime.utcnow() >= self.token_expires_at + + def __repr__(self): + return f'' + + +class SchwabAccount(db.Model): + """Maps one Schwab account (identified by its encrypted hash) to a PFM account.""" + __tablename__ = 'schwab_accounts' + + id = db.Column(db.Integer, primary_key=True) + connection_id = db.Column(db.Integer, db.ForeignKey('schwab_connections.id'), nullable=False) + account_hash = db.Column(db.String(100), unique=True, nullable=False, index=True) + account_number_display = db.Column(db.String(20), nullable=True) # masked, e.g. "…4321" + account_type = db.Column(db.String(50), nullable=True) # CASH / MARGIN / etc. + account_name = db.Column(db.String(100), nullable=True) # display label + pfm_account_id = db.Column(db.Integer, db.ForeignKey('accounts.id'), nullable=True) + last_sync_date = db.Column(db.Date, nullable=True) + last_schwab_txn_id = db.Column(db.String(64), nullable=True) + is_active = db.Column(db.Boolean, default=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + connection = db.relationship('SchwabConnection', back_populates='accounts') + pfm_account = db.relationship('Account') + + def __repr__(self): + return f'' diff --git a/app/routes/schwab.py b/app/routes/schwab.py new file mode 100644 index 0000000..27e943d --- /dev/null +++ b/app/routes/schwab.py @@ -0,0 +1,274 @@ +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')) diff --git a/app/services/schwab_service.py b/app/services/schwab_service.py new file mode 100644 index 0000000..23be9a9 --- /dev/null +++ b/app/services/schwab_service.py @@ -0,0 +1,315 @@ +""" +Schwab Developer API Service — OAuth 2.0 + Trader API + +Auth flow: + 1. Redirect user to SCHWAB_AUTH_URL with client_id + redirect_uri + state + 2. Schwab calls back with ?code=...&state=... + 3. Exchange code for access_token + refresh_token (Basic Auth: client_id:client_secret) + 4. Access token expires in 30 min — auto-refresh via refresh_token (valid 7 days) + +Endpoints used: + GET /trader/v1/accounts → list accounts + GET /trader/v1/accounts/{hash}/transactions?startDate&endDate → transactions +""" + +import base64 +import logging +import os +from datetime import date, datetime, timedelta + +import requests +from flask import current_app + +log = logging.getLogger(__name__) + +SCHWAB_AUTH_URL = 'https://api.schwabapi.com/v1/oauth/authorize' +SCHWAB_TOKEN_URL = 'https://api.schwabapi.com/v1/oauth/token' +SCHWAB_BASE = 'https://api.schwabapi.com' + +# Schwab transaction type → PFM category name +CATEGORY_MAP = { + 'DIVIDEND_OR_INTEREST': 'Investment', + 'TRADE': 'Investment', + 'BUY': 'Investment', + 'SELL': 'Investment', + 'ACH_RECEIPT': 'Other Income', + 'ACH_DISBURSEMENT': 'Other', + 'WIRE_IN': 'Other Income', + 'WIRE_OUT': 'Other', + 'CASH_RECEIPT': 'Other Income', + 'CASH_DISBURSEMENT': 'Other', + 'ELECTRONIC_FUND': 'Other', + 'RECEIVE_AND_DELIVER': 'Investment', + 'TRANSFER_OF_ACCOUNT_IN': 'Other Income', + 'TRANSFER_OF_ACCOUNT_OUT': 'Other', + 'JOURNAL': 'Other', + 'PASS_THROUGH_CHARGE': 'Other', + 'PASS_THROUGH_REBATE': 'Other Income', + 'TRUST_FEES': 'Other', + 'MEMORIAL': 'Other', +} + +# Schwab account type → PFM account type +ACCOUNT_TYPE_MAP = { + 'CASH': 'checking', + 'MARGIN': 'investment', +} + + +def get_auth_url(): + cfg = current_app.config + client_id = cfg['SCHWAB_CLIENT_ID'] + redirect_uri = cfg['SCHWAB_REDIRECT_URI'] + import urllib.parse, secrets + state = secrets.token_urlsafe(16) + params = urllib.parse.urlencode({ + 'client_id': client_id, + 'redirect_uri': redirect_uri, + 'response_type': 'code', + 'scope': 'readonly', + }) + return f'{SCHWAB_AUTH_URL}?{params}', state + + +def _basic_auth_header(): + cfg = current_app.config + creds = f"{cfg['SCHWAB_CLIENT_ID']}:{cfg['SCHWAB_CLIENT_SECRET']}" + return 'Basic ' + base64.b64encode(creds.encode()).decode() + + +def exchange_code(code): + """Exchange authorization code for tokens. Returns token dict.""" + cfg = current_app.config + resp = requests.post( + SCHWAB_TOKEN_URL, + headers={ + 'Authorization': _basic_auth_header(), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + data={ + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': cfg['SCHWAB_REDIRECT_URI'], + }, + timeout=15, + ) + _raise_for_status(resp, 'exchange_code') + return resp.json() + + +def refresh_tokens(connection): + """ + Refresh access token using stored refresh_token. + Updates connection object in-place and commits to DB. + Raises on failure. + """ + resp = requests.post( + SCHWAB_TOKEN_URL, + headers={ + 'Authorization': _basic_auth_header(), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + data={ + 'grant_type': 'refresh_token', + 'refresh_token': connection.refresh_token, + }, + timeout=15, + ) + _raise_for_status(resp, 'refresh_tokens') + data = resp.json() + _apply_token_data(connection, data) + from app.extensions import db + db.session.commit() + log.info('[schwab] tokens refreshed for connection id=%s', connection.id) + return connection + + +def _apply_token_data(connection, data): + """Write token fields from token response onto connection model.""" + connection.access_token = data['access_token'] + connection.refresh_token = data.get('refresh_token', connection.refresh_token) + expires_in = int(data.get('expires_in', 1800)) + connection.token_expires_at = datetime.utcnow() + timedelta(seconds=expires_in - 60) + + +def _ensure_fresh(connection): + """Auto-refresh access token if within 60 seconds of expiry.""" + if connection.token_is_expired: + log.info('[schwab] access token expired — refreshing') + refresh_tokens(connection) + + +def _authed_get(connection, path, params=None): + """GET request with auto token refresh. Returns parsed JSON.""" + _ensure_fresh(connection) + url = SCHWAB_BASE + path + resp = requests.get( + url, + headers={'Authorization': f'Bearer {connection.access_token}'}, + params=params or {}, + timeout=30, + ) + _raise_for_status(resp, f'GET {path}') + return resp.json() + + +def _raise_for_status(resp, context=''): + if not resp.ok: + log.error('[schwab] API error (%s) — status=%s body=%r', + context, resp.status_code, resp.text[:2000]) + resp.raise_for_status() + + +# ── Public API helpers ──────────────────────────────────────────────────────── + +def get_accounts(connection): + """Return list of Schwab account dicts.""" + data = _authed_get(connection, '/trader/v1/accounts', params={'fields': 'positions'}) + log.info('[schwab] get_accounts: returned %d account(s)', len(data)) + return data + + +def get_transactions(connection, account_hash, start_date, end_date): + """ + Fetch transactions for one account. + start_date / end_date: date objects or ISO strings. + Returns list of transaction dicts. + """ + def _iso(d): + if hasattr(d, 'strftime'): + return d.strftime('%Y-%m-%dT00:00:00.000Z') + return d + + params = { + 'startDate': _iso(start_date), + 'endDate': _iso(end_date), + } + data = _authed_get(connection, f'/trader/v1/accounts/{account_hash}/transactions', params) + log.info('[schwab] get_transactions: account=%s returned %d txn(s)', + account_hash[:8] + '…', len(data)) + return data + + +def build_category_map(): + from app.models.category import Category + cats = Category.query.filter_by(is_active=True).all() + return {c.name: c.id for c in cats} + + +def parse_transaction(schwab_txn, pfm_account_id, cat_id_map): + """ + Convert a Schwab transaction dict to a PFM-ready dict. + + Schwab netAmount convention: + positive → money came INTO the account (income) + negative → money LEFT the account (expense) + """ + net = float(schwab_txn.get('netAmount', 0)) + if net >= 0: + txn_type = 'income' + amount = net + else: + txn_type = 'expense' + amount = abs(net) + + # Prefer description, fall back to type + description = (schwab_txn.get('description') or + schwab_txn.get('type', 'Schwab transaction')).strip() + + schwab_type = schwab_txn.get('type', '') + cat_name = CATEGORY_MAP.get(schwab_type, 'Other') + category_id = cat_id_map.get(cat_name) + + # Parse date — Schwab uses ISO-8601 with timezone offset + raw_time = schwab_txn.get('time', '') + try: + txn_date = datetime.fromisoformat(raw_time.replace('Z', '+00:00')).date() + except (ValueError, AttributeError): + txn_date = date.today() + + activity_id = str(schwab_txn.get('activityId', '')) + + return { + 'schwab_id': activity_id, + 'date': txn_date, + 'transaction_type': txn_type, + 'amount': amount, + 'description': description, + 'account_id': pfm_account_id, + 'category_id': category_id, + 'notes': f'Schwab:{activity_id}', + 'schwab_type': schwab_type, + } + + +def sync_preview(schwab_account, days_back=90): + """ + Fetch and parse transactions for a SchwabAccount. + Returns list of parsed dicts — does NOT write to DB. + """ + connection = schwab_account.connection + today = date.today() + + if schwab_account.last_sync_date: + start = schwab_account.last_sync_date - timedelta(days=7) + else: + start = today - timedelta(days=days_back) + + raw_txns = get_transactions( + connection, + schwab_account.account_hash, + start_date=start, + end_date=today, + ) + + cat_map = build_category_map() + return [ + parse_transaction(t, schwab_account.pfm_account_id, cat_map) + for t in raw_txns + if float(t.get('netAmount', 0)) != 0 # skip zero-amount entries + ] + + +def import_transactions(parsed_txns, schwab_account): + """ + Import parsed transactions. Skips duplicates via Schwab: in notes. + Returns (imported_count, skipped_count). + """ + from app.extensions import db + from app.models.transaction import Transaction + from app.services.account_service import calc_balance + + imported = skipped = 0 + affected = set() + + for p in parsed_txns: + sid = p['schwab_id'] + if Transaction.query.filter(Transaction.notes.like(f'%Schwab:{sid}%')).first(): + skipped += 1 + continue + + db.session.add(Transaction( + account_id = p['account_id'], + category_id = p.get('category_id'), + transaction_type = p['transaction_type'], + amount = p['amount'], + description = p['description'], + date = p['date'], + notes = p['notes'], + )) + if p['account_id']: + affected.add(p['account_id']) + imported += 1 + + db.session.commit() + + schwab_account.last_sync_date = date.today() + schwab_account.connection.last_synced_at = datetime.utcnow() + if parsed_txns: + schwab_account.last_schwab_txn_id = parsed_txns[0]['schwab_id'] + db.session.commit() + + for acct_id in affected: + calc_balance(acct_id) + + return imported, skipped diff --git a/app/templates/base.html b/app/templates/base.html index c3aa547..ace526f 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -234,11 +234,23 @@ > Accounts + + Teller Sync + + + Schwab Sync + - Import Statement + Import Statement
Planning
diff --git a/app/templates/schwab/index.html b/app/templates/schwab/index.html new file mode 100644 index 0000000..19d5f1d --- /dev/null +++ b/app/templates/schwab/index.html @@ -0,0 +1,144 @@ +{% extends "base.html" %} +{% block title %}Schwab Connection{% endblock %} +{% block page_title %}Schwab Bank Sync{% endblock %} + +{% block topbar_actions %} +{% if connection %} +
+ + +
+{% endif %} +{% endblock %} + +{% block content %} + +{% if not schwab_configured %} +
+
+ +
+
Schwab credentials not configured
+

+ Register your app at developer.schwab.com, then add these to your .env: +

+
SCHWAB_CLIENT_ID=your-client-id
+SCHWAB_CLIENT_SECRET=your-client-secret
+SCHWAB_REDIRECT_URI=https://pfm.ngodanguyen.tech/schwab/callback
+

+ Also register https://pfm.ngodanguyen.tech/schwab/callback as the redirect URI in the Schwab developer portal, then restart the app. +

+
+
+
+{% endif %} + +{% if connection %} + +
+
+
+
+ +
+
+
Charles Schwab
+
+ Connected {{ connection.created_at.strftime('%b %d, %Y') }} · + Last synced: {{ connection.last_synced_at.strftime('%b %d, %H:%M') if connection.last_synced_at else 'Never' }} + {% if connection.token_is_expired %} + · Token expired — sync will auto-refresh + {% endif %} +
+
+
+ + Manage Mapping + +
+
+ +{% if accounts %} +
+
+ Accounts +
+ {% for sa in accounts %} +
+
+
+ +
+
+
+ {{ sa.account_name }} + {% if sa.last_sync_date %} + synced {{ sa.last_sync_date.strftime('%b %d') }} + {% endif %} +
+
+ {{ sa.account_number_display }} · {{ sa.account_type }} + {% if sa.pfm_account %} + · → {{ sa.pfm_account.name }} + {% else %} + · Not mapped + {% endif %} +
+
+
+
+ {% if sa.pfm_account %} + + Sync + +
+ + +
+ {% else %} + Map Account + {% endif %} +
+
+ {% endfor %} +
+ +{% else %} +
+ +
No accounts mapped
+

Link each Schwab account to a PFM account to start syncing.

+ Map Accounts +
+{% endif %} + +{% else %} + +
+ +
Connect Charles Schwab
+

+ Sync checking, savings, and brokerage transactions directly from Schwab. +

+ {% if schwab_configured %} + + Connect Schwab Account + + {% else %} + +

Configure credentials first (see above)

+ {% endif %} +
+{% endif %} + +{% endblock %} diff --git a/app/templates/schwab/map_accounts.html b/app/templates/schwab/map_accounts.html new file mode 100644 index 0000000..96a6830 --- /dev/null +++ b/app/templates/schwab/map_accounts.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% block title %}Map Schwab Accounts{% endblock %} +{% block page_title %}Map Schwab Accounts{% endblock %} + +{% block content %} +
+
+ +
+
+ + Link each Schwab account to a PFM account, or create a new one automatically. + Accounts set to "Skip" won't be synced. +
+
+ +
+
+ + {% for sa in schwab_accounts %} +
+
+
+ +
+
+
{{ sa.account_name }}
+
{{ sa.account_number_display }} · {{ sa.account_type }}
+
+
+ +
+ {% endfor %} + +
+ + Cancel +
+
+
+ +
+
+{% endblock %} diff --git a/app/templates/schwab/preview.html b/app/templates/schwab/preview.html new file mode 100644 index 0000000..8dd7671 --- /dev/null +++ b/app/templates/schwab/preview.html @@ -0,0 +1,139 @@ +{% extends "base.html" %} +{% block title %}Schwab Sync Preview{% endblock %} +{% block page_title %}Sync Preview — {{ sa.account_name }}{% endblock %} + +{% block content %} +{% if preview %} +
+ + +
+
+
{{ sa.account_name }} ({{ sa.account_number_display }})
+
+ Mapped to: {{ sa.pfm_account.name }} · + {{ count }} of {{ count }} selected + {% if sa.last_sync_date %}· Last sync: {{ sa.last_sync_date.strftime('%b %d, %Y') }}{% endif %} +
+
+
+ Cancel + +
+
+ +
+ + + + + + + + + + + + + + {% for txn in preview %} + + + + + + + + + + {% endfor %} + +
+ + DateDescriptionSchwab TypePFM TypeCategoryAmount
+ + + {{ txn.date.strftime('%b %d, %Y') }} + {{ txn.description }} + {{ txn.schwab_type | replace('_',' ') | title }} + + + + {% if txn.category_id and txn.category_id in cat_map %} + {{ cat_map[txn.category_id] }} + {% else %} + Uncategorised + {% endif %} + + {% if txn.transaction_type == 'income' %}+{% else %}-{% endif %}{{ txn.amount | currency }} +
+
+ +
+ +
+
+ +{% else %} +
+ +
All up to date
+

No new transactions found since last sync.

+ Back +
+{% endif %} + + +{% endblock %} diff --git a/app/templates/settings/index.html b/app/templates/settings/index.html index d75a9c6..1cbd681 100644 --- a/app/templates/settings/index.html +++ b/app/templates/settings/index.html @@ -9,10 +9,19 @@
-
Bank Connections
+
Teller Sync
Connect US bank accounts via Teller
+ +