06/05 Optimize app
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,17 +455,14 @@ 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}%')
|
db.session.delete(txn)
|
||||||
).first()
|
removed_count += 1
|
||||||
if txn:
|
|
||||||
db.session.delete(txn)
|
|
||||||
removed_count += 1
|
|
||||||
if removed_count:
|
if removed_count:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
log.info('[plaid] auto_sync removed %d transaction(s) for item %s',
|
log.info('[plaid] auto_sync removed %d transaction(s) for item %s',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user