06/02 Integrate Schwab
This commit is contained in:
@@ -89,6 +89,7 @@ def create_app(config_name=None):
|
|||||||
from app.routes.reports import reports_bp
|
from app.routes.reports import reports_bp
|
||||||
from app.routes.settings import settings_bp
|
from app.routes.settings import settings_bp
|
||||||
from app.routes.teller import teller_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.logs import logs_bp
|
||||||
from app.routes.bank_import import bank_import_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(reports_bp)
|
||||||
app.register_blueprint(settings_bp)
|
app.register_blueprint(settings_bp)
|
||||||
app.register_blueprint(teller_bp)
|
app.register_blueprint(teller_bp)
|
||||||
|
app.register_blueprint(schwab_bp)
|
||||||
app.register_blueprint(logs_bp)
|
app.register_blueprint(logs_bp)
|
||||||
app.register_blueprint(bank_import_bp)
|
app.register_blueprint(bank_import_bp)
|
||||||
|
|
||||||
@@ -115,6 +117,7 @@ def create_app(config_name=None):
|
|||||||
AiInsight, FxRate
|
AiInsight, FxRate
|
||||||
)
|
)
|
||||||
from app.models.teller_enrollment import TellerEnrollment, TellerAccount
|
from app.models.teller_enrollment import TellerEnrollment, TellerAccount
|
||||||
|
from app.models.schwab_connection import SchwabConnection, SchwabAccount
|
||||||
|
|
||||||
@app.after_request
|
@app.after_request
|
||||||
def security_headers(response):
|
def security_headers(response):
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ class Config:
|
|||||||
TELLER_KEY_PATH = os.environ.get('TELLER_KEY_PATH', '/home/pfm/teller/private_key.pem')
|
TELLER_KEY_PATH = os.environ.get('TELLER_KEY_PATH', '/home/pfm/teller/private_key.pem')
|
||||||
TELLER_WEBHOOK_SECRET = os.environ.get('TELLER_WEBHOOK_SECRET', '')
|
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)
|
# Budget alert emails (optional — all four must be set to enable)
|
||||||
SMTP_HOST = os.environ.get('SMTP_HOST', '')
|
SMTP_HOST = os.environ.get('SMTP_HOST', '')
|
||||||
SMTP_PORT = int(os.environ.get('SMTP_PORT', 587))
|
SMTP_PORT = int(os.environ.get('SMTP_PORT', 587))
|
||||||
|
|||||||
@@ -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'<SchwabConnection id={self.id} expires={self.token_expires_at}>'
|
||||||
|
|
||||||
|
|
||||||
|
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'<SchwabAccount {self.account_number_display} → PFM #{self.pfm_account_id}>'
|
||||||
@@ -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/<int:schwab_account_id>')
|
||||||
|
@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/<int:schwab_account_id>', 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'))
|
||||||
@@ -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:<activityId> 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
|
||||||
+13
-1
@@ -234,11 +234,23 @@
|
|||||||
>
|
>
|
||||||
<i class="bi bi-wallet2"></i><span class="lt">Accounts</span>
|
<i class="bi bi-wallet2"></i><span class="lt">Accounts</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
href="{{ url_for('teller.index') }}"
|
||||||
|
class="sb-link {% if request.blueprint == 'teller' %}active{% endif %}"
|
||||||
|
>
|
||||||
|
<i class="bi bi-bank2"></i><span class="lt">Teller Sync</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="{{ url_for('schwab.index') }}"
|
||||||
|
class="sb-link {% if request.blueprint == 'schwab' %}active{% endif %}"
|
||||||
|
>
|
||||||
|
<i class="bi bi-bank"></i><span class="lt">Schwab Sync</span>
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
href="{{ url_for('bank_import.index') }}"
|
href="{{ url_for('bank_import.index') }}"
|
||||||
class="sb-link {% if request.blueprint == 'bank_import' %}active{% endif %}"
|
class="sb-link {% if request.blueprint == 'bank_import' %}active{% endif %}"
|
||||||
>
|
>
|
||||||
<i class="bi bi-bank2"></i><span class="lt">Import Statement</span>
|
<i class="bi bi-upload"></i><span class="lt">Import Statement</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="sb-section">Planning</div>
|
<div class="sb-section">Planning</div>
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Schwab Connection{% endblock %}
|
||||||
|
{% block page_title %}Schwab Bank Sync{% endblock %}
|
||||||
|
|
||||||
|
{% block topbar_actions %}
|
||||||
|
{% if connection %}
|
||||||
|
<form method="POST" action="{{ url_for('schwab.disconnect') }}"
|
||||||
|
onsubmit="return confirm('Disconnect Schwab? Imported transactions are kept.')" class="d-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger" style="font-size:12px;">Disconnect</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% if not schwab_configured %}
|
||||||
|
<div class="pcard mb-4" style="border-left:4px solid #f59e0b;">
|
||||||
|
<div class="d-flex align-items-start gap-3">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill text-warning mt-1" style="font-size:1.4rem;flex-shrink:0;"></i>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;font-size:14px;margin-bottom:6px;">Schwab credentials not configured</div>
|
||||||
|
<p style="font-size:13px;color:var(--muted);margin-bottom:10px;">
|
||||||
|
Register your app at <strong>developer.schwab.com</strong>, then add these to your <code>.env</code>:
|
||||||
|
</p>
|
||||||
|
<pre style="font-size:12px;background:#f8fafc;padding:12px;border-radius:8px;margin:0;overflow-x:auto;">SCHWAB_CLIENT_ID=your-client-id
|
||||||
|
SCHWAB_CLIENT_SECRET=your-client-secret
|
||||||
|
SCHWAB_REDIRECT_URI=https://pfm.ngodanguyen.tech/schwab/callback</pre>
|
||||||
|
<p style="font-size:12px;color:var(--muted);margin-top:8px;">
|
||||||
|
Also register <code>https://pfm.ngodanguyen.tech/schwab/callback</code> as the redirect URI in the Schwab developer portal, then restart the app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if connection %}
|
||||||
|
<!-- Connected state -->
|
||||||
|
<div class="pcard mb-4" style="border-left:4px solid #10b981;">
|
||||||
|
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<div style="width:42px;height:42px;border-radius:10px;background:#d1fae5;color:#065f46;display:flex;align-items:center;justify-content:center;font-size:20px;">
|
||||||
|
<i class="bi bi-bank2"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;font-size:15px;">Charles Schwab</div>
|
||||||
|
<div style="font-size:12px;color:var(--muted);">
|
||||||
|
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 %}
|
||||||
|
· <span style="color:#f59e0b;"><i class="bi bi-exclamation-circle me-1"></i>Token expired — sync will auto-refresh</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('schwab.map_accounts') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">
|
||||||
|
<i class="bi bi-diagram-2 me-1"></i>Manage Mapping
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if accounts %}
|
||||||
|
<div class="pcard p-0">
|
||||||
|
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-bottom:1px solid var(--border);">
|
||||||
|
<span class="pcard-title mb-0">Accounts</span>
|
||||||
|
</div>
|
||||||
|
{% for sa in accounts %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="{% if not loop.last %}border-bottom:1px solid var(--border);{% endif %}">
|
||||||
|
<div class="d-flex align-items-center gap-2" style="min-width:0;">
|
||||||
|
<div style="width:32px;height:32px;border-radius:8px;background:#dbeafe;color:#1e40af;display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0;">
|
||||||
|
<i class="bi bi-bank"></i>
|
||||||
|
</div>
|
||||||
|
<div style="min-width:0;">
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span style="font-size:13px;font-weight:500;">{{ sa.account_name }}</span>
|
||||||
|
{% if sa.last_sync_date %}
|
||||||
|
<span style="font-size:10px;color:var(--muted);">synced {{ sa.last_sync_date.strftime('%b %d') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;color:var(--muted);">
|
||||||
|
{{ sa.account_number_display }} · {{ sa.account_type }}
|
||||||
|
{% if sa.pfm_account %}
|
||||||
|
· <span style="color:var(--muted);">→ {{ sa.pfm_account.name }}</span>
|
||||||
|
{% else %}
|
||||||
|
· <span style="color:#f59e0b;">Not mapped</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-shrink-0 ms-2">
|
||||||
|
{% if sa.pfm_account %}
|
||||||
|
<a href="{{ url_for('schwab.sync_preview_view', schwab_account_id=sa.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-primary" style="font-size:11px;">
|
||||||
|
<i class="bi bi-cloud-download me-1"></i>Sync
|
||||||
|
</a>
|
||||||
|
<form method="POST" action="{{ url_for('schwab.full_resync', schwab_account_id=sa.id) }}"
|
||||||
|
style="display:inline;"
|
||||||
|
onsubmit="return confirm('Reset sync cursor for {{ sa.account_name }}?\nNext sync re-fetches 90 days. Duplicates are skipped.')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-warning" style="font-size:11px;"
|
||||||
|
title="Re-fetch full 90-day history on next sync">
|
||||||
|
<i class="bi bi-arrow-counterclockwise"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('schwab.map_accounts') }}"
|
||||||
|
class="btn btn-sm btn-outline-warning" style="font-size:11px;">Map Account</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="pcard text-center py-5">
|
||||||
|
<i class="bi bi-diagram-2 text-muted" style="font-size:3rem;"></i>
|
||||||
|
<h5 class="mt-3 mb-1">No accounts mapped</h5>
|
||||||
|
<p class="text-muted small mb-3">Link each Schwab account to a PFM account to start syncing.</p>
|
||||||
|
<a href="{{ url_for('schwab.map_accounts') }}" class="btn btn-primary btn-sm">Map Accounts</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- Not connected -->
|
||||||
|
<div class="pcard text-center py-5 col-12 col-lg-6 mx-auto">
|
||||||
|
<i class="bi bi-bank2 text-muted" style="font-size:3rem;"></i>
|
||||||
|
<h5 class="mt-3 mb-1">Connect Charles Schwab</h5>
|
||||||
|
<p class="text-muted small mb-4">
|
||||||
|
Sync checking, savings, and brokerage transactions directly from Schwab.
|
||||||
|
</p>
|
||||||
|
{% if schwab_configured %}
|
||||||
|
<a href="{{ url_for('schwab.connect') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-link-45deg me-1"></i>Connect Schwab Account
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn-primary" disabled>
|
||||||
|
<i class="bi bi-link-45deg me-1"></i>Connect Schwab Account
|
||||||
|
</button>
|
||||||
|
<p class="text-muted small mt-2">Configure credentials first (see above)</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Map Schwab Accounts{% endblock %}
|
||||||
|
{% block page_title %}Map Schwab Accounts{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-12 col-lg-8">
|
||||||
|
|
||||||
|
<div class="pcard mb-3" style="background:#f0fdf4;border-color:#bbf7d0;">
|
||||||
|
<div style="font-size:13px;color:#166534;">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
Link each Schwab account to a PFM account, or create a new one automatically.
|
||||||
|
Accounts set to "Skip" won't be synced.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pcard">
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
{% for sa in schwab_accounts %}
|
||||||
|
<div class="{% if not loop.last %}mb-4 pb-4{% endif %}" style="{% if not loop.last %}border-bottom:1px solid var(--border);{% endif %}">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-2">
|
||||||
|
<div style="width:32px;height:32px;border-radius:8px;background:#dbeafe;color:#1e40af;display:flex;align-items:center;justify-content:center;font-size:14px;">
|
||||||
|
<i class="bi bi-bank"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:14px;font-weight:600;">{{ sa.account_name }}</div>
|
||||||
|
<div style="font-size:11px;color:var(--muted);">{{ sa.account_number_display }} · {{ sa.account_type }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<select name="pfm_account_{{ sa.id }}" class="form-select form-select-sm" style="font-size:13px;">
|
||||||
|
<option value="">— Skip this account —</option>
|
||||||
|
<option value="new" {% if not sa.pfm_account_id %}{% endif %}>+ Create new PFM account</option>
|
||||||
|
{% for acct in pfm_accounts %}
|
||||||
|
<option value="{{ acct.id }}" {% if sa.pfm_account_id == acct.id %}selected{% endif %}>
|
||||||
|
{{ acct.name }} ({{ acct.account_type | replace('_',' ') | title }})
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 mt-4">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Mapping</button>
|
||||||
|
<a href="{{ url_for('schwab.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -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 %}
|
||||||
|
<form method="POST" action="{{ url_for('schwab.sync_confirm') }}" id="importForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="pcard mb-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:14px;font-weight:600;">{{ sa.account_name }} ({{ sa.account_number_display }})</div>
|
||||||
|
<div style="font-size:12px;color:var(--muted);">
|
||||||
|
Mapped to: <strong>{{ sa.pfm_account.name }}</strong> ·
|
||||||
|
<span id="selectedCount">{{ count }}</span> of {{ count }} selected
|
||||||
|
{% if sa.last_sync_date %}· Last sync: {{ sa.last_sync_date.strftime('%b %d, %Y') }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ url_for('schwab.index') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-sm btn-success" style="font-size:12px;" id="importBtn">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>Import <span id="importBtnCount">{{ count }}</span> Transaction(s)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pcard p-0">
|
||||||
|
<table class="pfm-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:36px;padding-left:16px;">
|
||||||
|
<input type="checkbox" id="selectAll" checked style="width:15px;height:15px;cursor:pointer;">
|
||||||
|
</th>
|
||||||
|
<th style="white-space:nowrap;">Date</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="d-mob-none">Schwab Type</th>
|
||||||
|
<th style="width:160px;">PFM Type</th>
|
||||||
|
<th class="d-mob-none">Category</th>
|
||||||
|
<th class="text-end" style="padding-right:20px;">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for txn in preview %}
|
||||||
|
<tr class="preview-row" data-id="{{ txn.schwab_id }}">
|
||||||
|
<td style="padding-left:16px;">
|
||||||
|
<input type="checkbox" name="selected" value="{{ txn.schwab_id }}"
|
||||||
|
class="row-check" checked style="width:15px;height:15px;cursor:pointer;">
|
||||||
|
</td>
|
||||||
|
<td style="font-size:12px;color:var(--muted);white-space:nowrap;">
|
||||||
|
{{ txn.date.strftime('%b %d, %Y') }}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:13px;">{{ txn.description }}</td>
|
||||||
|
<td class="d-mob-none" style="font-size:11px;color:var(--muted);">
|
||||||
|
{{ txn.schwab_type | replace('_',' ') | title }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select name="type_{{ txn.schwab_id }}" class="type-select form-select form-select-sm"
|
||||||
|
style="width:130px;font-size:12px;border-radius:6px;
|
||||||
|
{% if txn.transaction_type == 'income' %}border-color:#10b981;color:#10b981;{% else %}border-color:#ef4444;color:#ef4444;{% endif %}">
|
||||||
|
<option value="expense" {% if txn.transaction_type == 'expense' %}selected{% endif %}>Expense</option>
|
||||||
|
<option value="income" {% if txn.transaction_type == 'income' %}selected{% endif %}>Income</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="d-mob-none" style="font-size:12px;color:var(--muted);">
|
||||||
|
{% if txn.category_id and txn.category_id in cat_map %}
|
||||||
|
{{ cat_map[txn.category_id] }}
|
||||||
|
{% else %}
|
||||||
|
<span style="color:#f59e0b;">Uncategorised</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end mono amount-cell
|
||||||
|
{% if txn.transaction_type == 'income' %}text-income{% else %}text-expense{% endif %}"
|
||||||
|
style="font-size:13px;font-weight:600;padding-right:20px;">
|
||||||
|
<span class="sign">{% if txn.transaction_type == 'income' %}+{% else %}-{% endif %}</span>{{ txn.amount | currency }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-end mt-3">
|
||||||
|
<button type="submit" class="btn btn-success">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>Confirm Import (<span id="importBtnCount2">{{ count }}</span> transactions)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="pcard text-center py-5">
|
||||||
|
<i class="bi bi-check-circle text-success" style="font-size:3rem;"></i>
|
||||||
|
<h5 class="mt-3 mb-1">All up to date</h5>
|
||||||
|
<p class="text-muted small">No new transactions found since last sync.</p>
|
||||||
|
<a href="{{ url_for('schwab.index') }}" class="btn btn-sm btn-outline-secondary">Back</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const selectAll = document.getElementById('selectAll');
|
||||||
|
const checks = document.querySelectorAll('.row-check');
|
||||||
|
const countEl = document.getElementById('selectedCount');
|
||||||
|
const btnCount = document.getElementById('importBtnCount');
|
||||||
|
const btnCount2 = document.getElementById('importBtnCount2');
|
||||||
|
|
||||||
|
function updateCount() {
|
||||||
|
const n = document.querySelectorAll('.row-check:checked').length;
|
||||||
|
countEl.textContent = n;
|
||||||
|
btnCount.textContent = n;
|
||||||
|
btnCount2.textContent = n;
|
||||||
|
document.querySelectorAll('.preview-row').forEach(row => {
|
||||||
|
row.style.opacity = row.querySelector('.row-check').checked ? '1' : '0.4';
|
||||||
|
});
|
||||||
|
selectAll.indeterminate = n > 0 && n < checks.length;
|
||||||
|
selectAll.checked = n === checks.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAll.addEventListener('change', () => {
|
||||||
|
checks.forEach(cb => { cb.checked = selectAll.checked; });
|
||||||
|
updateCount();
|
||||||
|
});
|
||||||
|
checks.forEach(cb => cb.addEventListener('change', updateCount));
|
||||||
|
|
||||||
|
document.querySelectorAll('.type-select').forEach(sel => {
|
||||||
|
sel.addEventListener('change', function () {
|
||||||
|
const isIncome = this.value === 'income';
|
||||||
|
this.style.borderColor = isIncome ? '#10b981' : '#ef4444';
|
||||||
|
this.style.color = isIncome ? '#10b981' : '#ef4444';
|
||||||
|
const row = this.closest('.preview-row');
|
||||||
|
const cell = row.querySelector('.amount-cell');
|
||||||
|
const sign = row.querySelector('.sign');
|
||||||
|
cell.classList.toggle('text-income', isIncome);
|
||||||
|
cell.classList.toggle('text-expense', !isIncome);
|
||||||
|
sign.textContent = isIncome ? '+' : '-';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -9,10 +9,19 @@
|
|||||||
<a href="{{ url_for('teller.index') }}" class="text-decoration-none">
|
<a href="{{ url_for('teller.index') }}" class="text-decoration-none">
|
||||||
<div class="pcard text-center py-4" style="transition:all .15s;" onmouseover="this.style.borderColor='#10b981'" onmouseout="this.style.borderColor='var(--border)'">
|
<div class="pcard text-center py-4" style="transition:all .15s;" onmouseover="this.style.borderColor='#10b981'" onmouseout="this.style.borderColor='var(--border)'">
|
||||||
<i class="bi bi-bank2" style="font-size:2rem;color:#10b981;"></i>
|
<i class="bi bi-bank2" style="font-size:2rem;color:#10b981;"></i>
|
||||||
<div style="font-size:14px;font-weight:600;margin-top:10px;">Bank Connections</div>
|
<div style="font-size:14px;font-weight:600;margin-top:10px;">Teller Sync</div>
|
||||||
<div style="font-size:12px;color:var(--muted);margin-top:4px;">Connect US bank accounts via Teller</div>
|
<div style="font-size:12px;color:var(--muted);margin-top:4px;">Connect US bank accounts via Teller</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-sm-6 col-lg-3">
|
||||||
|
<a href="{{ url_for('schwab.index') }}" class="text-decoration-none">
|
||||||
|
<div class="pcard text-center py-4" style="transition:all .15s;" onmouseover="this.style.borderColor='#3b82f6'" onmouseout="this.style.borderColor='var(--border)'">
|
||||||
|
<i class="bi bi-bank" style="font-size:2rem;color:#3b82f6;"></i>
|
||||||
|
<div style="font-size:14px;font-weight:600;margin-top:10px;">Schwab Sync</div>
|
||||||
|
<div style="font-size:12px;color:var(--muted);margin-top:4px;">Connect Charles Schwab directly</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-6 col-lg-3">
|
<div class="col-12 col-sm-6 col-lg-3">
|
||||||
<a href="{{ url_for('settings.profile') }}" class="text-decoration-none">
|
<a href="{{ url_for('settings.profile') }}" class="text-decoration-none">
|
||||||
|
|||||||
Reference in New Issue
Block a user