05/31 Phase 2
This commit is contained in:
@@ -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))
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user