05/31 Phase 2

This commit is contained in:
2026-05-31 11:17:53 -04:00
parent 1c34dcbd28
commit 9f2a0acc38
18 changed files with 1817 additions and 391 deletions
View File
+85
View File
@@ -0,0 +1,85 @@
"""
Account Service — balance calculated from transactions.
Balance = sum of income - sum of expenses for an account,
plus any incoming transfers minus outgoing transfers.
"""
from decimal import Decimal
from sqlalchemy import func
from app.extensions import db
from app.models.account import Account
from app.models.transaction import Transaction
def calc_balance(account_id):
"""Recalculate and persist the balance for a given account."""
# Income credited to this account
income = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
).filter(
Transaction.account_id == account_id,
Transaction.transaction_type == 'income'
).scalar()
# Expenses debited from this account
expense = db.session.query(
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)
if account:
account.balance = balance
db.session.commit()
return balance
def recalc_all():
"""Recalculate balances for all accounts."""
for account in Account.query.filter_by(is_active=True).all():
calc_balance(account.id)
def get_total_assets():
"""Sum of all positive-balance accounts (non-credit)."""
result = db.session.query(
func.coalesce(func.sum(Account.balance), 0)
).filter(
Account.is_active == True,
Account.account_type != 'credit_card',
Account.balance > 0
).scalar()
return float(result)
def get_total_liabilities():
"""Sum of credit card balances (negative = owed)."""
result = db.session.query(
func.coalesce(func.sum(Account.balance), 0)
).filter(
Account.is_active == True,
Account.account_type == 'credit_card',
Account.balance < 0
).scalar()
return abs(float(result))
+86
View File
@@ -0,0 +1,86 @@
"""
FX Service — fetches and caches daily USD→VND exchange rate.
Primary source: open.er-api.com (free, no key)
Fallback: last known rate from DB
"""
import requests
from datetime import date, datetime
from app.extensions import db
from app.models.fx_rate import FxRate
ER_API_URL = 'https://open.er-api.com/v6/latest/USD'
REQUEST_TIMEOUT = 8 # seconds
def get_today_rate():
"""
Return today's USD→VND rate as a dict:
{ 'rate': 25450.00, 'date': date(...), 'source': '...', 'is_stale': False }
"""
today = date.today()
# 1. Check cache
cached = FxRate.query.filter_by(date=today).first()
if cached:
return {
'rate': float(cached.usd_to_vnd),
'date': cached.date,
'source': cached.source,
'is_stale': False,
}
# 2. Fetch from API
rate, source = _fetch_from_api()
if rate:
record = FxRate(
date=today,
usd_to_vnd=rate,
source=source,
fetched_at=datetime.utcnow(),
)
db.session.add(record)
try:
db.session.commit()
except Exception:
db.session.rollback()
return {'rate': rate, 'date': today, 'source': source, 'is_stale': False}
# 3. Fallback — last known rate
last = FxRate.query.order_by(FxRate.date.desc()).first()
if last:
return {
'rate': float(last.usd_to_vnd),
'date': last.date,
'source': last.source,
'is_stale': True,
}
# 4. Nothing available
return None
def _fetch_from_api():
"""Try open.er-api.com. Returns (rate, source) or (None, None)."""
try:
resp = requests.get(ER_API_URL, timeout=REQUEST_TIMEOUT)
if resp.status_code == 200:
data = resp.json()
vnd = data.get('rates', {}).get('VND')
if vnd:
return float(vnd), 'exchangerate-api'
except Exception:
pass
return None, None
def get_rate_history(days=30):
"""Return list of FxRate records for last N days, oldest first."""
from datetime import timedelta
since = date.today() - timedelta(days=days)
return (FxRate.query
.filter(FxRate.date >= since)
.order_by(FxRate.date.asc())
.all())