06/05 Optimize app

This commit is contained in:
2026-06-05 18:14:16 -04:00
parent e4007348f8
commit 231a9d2193
5 changed files with 92 additions and 102 deletions
+31 -34
View File
@@ -5,48 +5,45 @@ plus any incoming transfers minus outgoing transfers.
""" """
from decimal import Decimal from decimal import Decimal
from sqlalchemy import func from sqlalchemy import func, case, or_, and_
from app.extensions import db from app.extensions import db
from app.models.account import Account from app.models.account import Account
from app.models.transaction import Transaction from app.models.transaction import Transaction
def calc_balance(account_id): def calc_balance(account_id):
"""Recalculate and persist the balance for a given account.""" """
# Income credited to this account Recalculate and persist the balance for a given account.
income = db.session.query( Uses a single aggregation query with CASE expressions instead of 4 queries.
func.coalesce(func.sum(Transaction.amount), 0) """
row = db.session.query(
func.coalesce(func.sum(case(
(and_(Transaction.account_id == account_id,
Transaction.transaction_type == 'income'), Transaction.amount),
else_=0
)), 0),
func.coalesce(func.sum(case(
(and_(Transaction.account_id == account_id,
Transaction.transaction_type == 'expense'), Transaction.amount),
else_=0
)), 0),
func.coalesce(func.sum(case(
(and_(Transaction.account_id == account_id,
Transaction.transaction_type == 'transfer'), Transaction.amount),
else_=0
)), 0),
func.coalesce(func.sum(case(
(and_(Transaction.to_account_id == account_id,
Transaction.transaction_type == 'transfer'), Transaction.amount),
else_=0
)), 0),
).filter( ).filter(
Transaction.account_id == account_id, or_(Transaction.account_id == account_id,
Transaction.transaction_type == 'income' Transaction.to_account_id == account_id)
).scalar() ).one()
# Expenses debited from this account income, expense, transfer_out, transfer_in = (Decimal(str(v)) for v in row)
expense = db.session.query( balance = income - expense - transfer_out + transfer_in
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.account_id == account_id,
Transaction.transaction_type == 'expense'
).scalar()
# Transfers out (this account is source)
transfer_out = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.account_id == account_id,
Transaction.transaction_type == 'transfer'
).scalar()
# Transfers in (this account is destination)
transfer_in = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.to_account_id == account_id,
Transaction.transaction_type == 'transfer'
).scalar()
balance = Decimal(str(income)) - Decimal(str(expense)) \
- Decimal(str(transfer_out)) + Decimal(str(transfer_in))
account = db.session.get(Account, account_id) account = db.session.get(Account, account_id)
if account: if account:
+5
View File
@@ -436,6 +436,11 @@ def _build_cat_id_map():
return {c.name: c.id for c in Category.query.filter_by(is_active=True).all()} return {c.name: c.id for c in Category.query.filter_by(is_active=True).all()}
def build_category_map():
"""Public alias — shared by teller, plaid, and schwab services."""
return _build_cat_id_map()
# ── Enrichment ──────────────────────────────────────────────────────────────── # ── Enrichment ────────────────────────────────────────────────────────────────
def _enrich(raw_rows): def _enrich(raw_rows):
+17 -21
View File
@@ -217,12 +217,6 @@ def sync_transactions(item):
return added, modified, removed, cursor return added, modified, removed, cursor
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 _map_plaid_category(plaid_cats, txn_type): def _map_plaid_category(plaid_cats, txn_type):
"""Map Plaid category array to a PFM category name.""" """Map Plaid category array to a PFM category name."""
if not plaid_cats: if not plaid_cats:
@@ -292,6 +286,7 @@ def sync_preview(item):
added, _modified, _removed, next_cursor = sync_transactions(item) added, _modified, _removed, next_cursor = sync_transactions(item)
# Build maps # Build maps
from app.services.bank_import_service import build_category_map
plaid_accounts = PlaidAccount.query.filter_by(item_id=item.id, is_active=True).all() plaid_accounts = PlaidAccount.query.filter_by(item_id=item.id, is_active=True).all()
plaid_account_map = {pa.plaid_account_id: pa.pfm_account_id for pa in plaid_accounts} plaid_account_map = {pa.plaid_account_id: pa.pfm_account_id for pa in plaid_accounts}
cat_map = build_category_map() cat_map = build_category_map()
@@ -324,9 +319,17 @@ def import_transactions(parsed_txns, next_cursor, item):
affected_accounts = set() affected_accounts = set()
plaid_account_ids_synced = set() plaid_account_ids_synced = set()
# Batch duplicate check — one IN query instead of one LIKE per transaction
candidate_notes = {p['notes'] for p in parsed_txns} # 'Plaid:{id}'
existing_notes = {
r[0] for r in
db.session.query(Transaction.notes)
.filter(Transaction.notes.in_(candidate_notes))
.all()
} if candidate_notes else set()
for p in parsed_txns: for p in parsed_txns:
pid = p['plaid_id'] if p['notes'] in existing_notes:
if Transaction.query.filter(Transaction.notes.like(f'%Plaid:{pid}%')).first():
skipped += 1 skipped += 1
continue continue
@@ -344,18 +347,13 @@ def import_transactions(parsed_txns, next_cursor, item):
plaid_account_ids_synced.add(p['plaid_account_id']) plaid_account_ids_synced.add(p['plaid_account_id'])
imported += 1 imported += 1
db.session.commit() # Single commit — transactions + cursor + metadata together
# Advance cursor
item.cursor = next_cursor item.cursor = next_cursor
item.last_synced_at = datetime.utcnow() item.last_synced_at = datetime.utcnow()
# Update last_sync_date per PlaidAccount
today = date.today() today = date.today()
for pa in PlaidAccount.query.filter_by(item_id=item.id, is_active=True).all(): for pa in PlaidAccount.query.filter_by(item_id=item.id, is_active=True).all():
if pa.plaid_account_id in plaid_account_ids_synced: if pa.plaid_account_id in plaid_account_ids_synced:
pa.last_sync_date = today pa.last_sync_date = today
db.session.commit() db.session.commit()
# Refresh balances for mapped accounts # Refresh balances for mapped accounts
@@ -441,6 +439,7 @@ def auto_sync_item(item):
added, modified, removed, next_cursor = sync_transactions(item) added, modified, removed, next_cursor = sync_transactions(item)
from app.services.bank_import_service import build_category_map
plaid_accounts = PlaidAccount.query.filter_by(item_id=item.id, is_active=True).all() plaid_accounts = PlaidAccount.query.filter_by(item_id=item.id, is_active=True).all()
plaid_account_map = {pa.plaid_account_id: pa.pfm_account_id for pa in plaid_accounts} plaid_account_map = {pa.plaid_account_id: pa.pfm_account_id for pa in plaid_accounts}
cat_map = build_category_map() cat_map = build_category_map()
@@ -456,15 +455,12 @@ def auto_sync_item(item):
imported, skipped = import_transactions(parsed, next_cursor, item) imported, skipped = import_transactions(parsed, next_cursor, item)
# Remove transactions that Plaid says are gone (e.g. pending dropped) # Remove transactions that Plaid says are gone — batch lookup
removed_count = 0 removed_count = 0
if removed: if removed:
for r in removed: remove_notes = {f'Plaid:{r.get("transaction_id", "")}' for r in removed if r.get('transaction_id')}
tid = r.get('transaction_id', '') txns_to_delete = Transaction.query.filter(Transaction.notes.in_(remove_notes)).all()
txn = Transaction.query.filter( for txn in txns_to_delete:
Transaction.notes.like(f'%Plaid:{tid}%')
).first()
if txn:
db.session.delete(txn) db.session.delete(txn)
removed_count += 1 removed_count += 1
if removed_count: if removed_count:
+14 -17
View File
@@ -336,12 +336,6 @@ def get_transactions(connection, account_hash, start_date, end_date):
return 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): def parse_transaction(schwab_txn, pfm_account_id, cat_id_map):
""" """
Convert a Schwab transaction dict to a PFM-ready dict. Convert a Schwab transaction dict to a PFM-ready dict.
@@ -413,6 +407,7 @@ def sync_preview(schwab_account, days_back=90):
end_date=today, end_date=today,
) )
from app.services.bank_import_service import build_category_map
cat_map = build_category_map() cat_map = build_category_map()
return [ return [
parse_transaction(t, schwab_account.pfm_account_id, cat_map) parse_transaction(t, schwab_account.pfm_account_id, cat_map)
@@ -428,14 +423,20 @@ def import_transactions(parsed_txns, schwab_account):
""" """
from app.extensions import db from app.extensions import db
from app.models.transaction import Transaction from app.models.transaction import Transaction
from app.services.account_service import calc_balance
imported = skipped = 0 imported = skipped = 0
affected = set()
# Batch duplicate check — one IN query instead of one LIKE per transaction
candidate_notes = {p['notes'] for p in parsed_txns} # 'Schwab:{id}'
existing_notes = {
r[0] for r in
db.session.query(Transaction.notes)
.filter(Transaction.notes.in_(candidate_notes))
.all()
} if candidate_notes else set()
for p in parsed_txns: for p in parsed_txns:
sid = p['schwab_id'] if p['notes'] in existing_notes:
if Transaction.query.filter(Transaction.notes.like(f'%Schwab:{sid}%')).first():
skipped += 1 skipped += 1
continue continue
@@ -448,19 +449,15 @@ def import_transactions(parsed_txns, schwab_account):
date = p['date'], date = p['date'],
notes = p['notes'], notes = p['notes'],
)) ))
if p['account_id']:
affected.add(p['account_id'])
imported += 1 imported += 1
db.session.commit() # Single commit — transactions + metadata together
schwab_account.last_sync_date = date.today() schwab_account.last_sync_date = date.today()
schwab_account.connection.last_synced_at = datetime.utcnow() schwab_account.connection.last_synced_at = datetime.utcnow()
if parsed_txns: if parsed_txns:
schwab_account.last_schwab_txn_id = parsed_txns[0]['schwab_id'] schwab_account.last_schwab_txn_id = parsed_txns[0]['schwab_id']
db.session.commit() db.session.commit()
# Balance for Schwab accounts is set by sync_account_snapshot (liquidationValue),
for acct_id in affected: # not by summing transactions — skip calc_balance here.
calc_balance(acct_id)
return imported, skipped return imported, skipped
+23 -28
View File
@@ -231,13 +231,6 @@ def parse_transaction(teller_txn, pfm_account_id, category_id_map, is_credit_car
} }
def build_category_map():
"""Build {pfm_category_name: category_id} from DB."""
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 sync_preview(teller_account, days_back=90): def sync_preview(teller_account, days_back=90):
""" """
Fetch transactions for a TellerAccount and return a preview list. Fetch transactions for a TellerAccount and return a preview list.
@@ -272,6 +265,7 @@ def sync_preview(teller_account, days_back=90):
) )
raise raise
from app.services.bank_import_service import build_category_map
cat_map = build_category_map() cat_map = build_category_map()
is_cc = ( is_cc = (
teller_account.account_type == 'credit' or teller_account.account_type == 'credit' or
@@ -302,37 +296,38 @@ def import_transactions(parsed_txns, teller_account):
skipped = 0 skipped = 0
affected_accounts = set() affected_accounts = set()
# Batch duplicate check — one IN query instead of one LIKE per transaction
candidate_notes = {f'Teller:{p["teller_id"]}' for p in parsed_txns}
existing_notes = {
r[0] for r in
db.session.query(Transaction.notes)
.filter(Transaction.notes.in_(candidate_notes))
.all()
}
for p in parsed_txns: for p in parsed_txns:
# Duplicate check: match on teller_id in notes note_str = f'Teller:{p["teller_id"]}'
teller_id = p['teller_id'] if note_str in existing_notes:
existing = Transaction.query.filter(
Transaction.notes.like(f'%{teller_id}%')
).first()
if existing:
skipped += 1 skipped += 1
continue continue
txn = Transaction( db.session.add(Transaction(
account_id=p['account_id'], account_id = p['account_id'],
category_id=p.get('category_id'), category_id = p.get('category_id'),
transaction_type=p['transaction_type'], transaction_type = p['transaction_type'],
amount=p['amount'], amount = p['amount'],
description=p['description'], description = p['description'],
date=p['date'], date = p['date'],
notes=f"Teller:{teller_id}", notes = note_str,
) ))
db.session.add(txn)
if p['account_id']: if p['account_id']:
affected_accounts.add(p['account_id']) affected_accounts.add(p['account_id'])
imported += 1 imported += 1
db.session.commit() # Single commit — transactions + sync metadata together
# Update sync metadata on both the account and its parent enrollment
from datetime import datetime from datetime import datetime
now = datetime.utcnow()
teller_account.last_sync_date = date.today() teller_account.last_sync_date = date.today()
teller_account.enrollment.last_synced_at = now teller_account.enrollment.last_synced_at = datetime.utcnow()
if parsed_txns: if parsed_txns:
teller_account.last_teller_txn_id = parsed_txns[0]['teller_id'] teller_account.last_teller_txn_id = parsed_txns[0]['teller_id']
db.session.commit() db.session.commit()