Convert all currencies to CRC and poll rates every 6h
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s

Budget/transactions/salarios totals summed Transaction.amount directly,
so USD/EUR entries were treated as CRC and effectively disappeared from
the dashboard (the analytics fix in 9a80f2a only covered analytics).
Adds a shared get_converted_amount_expr() helper driven by the full
Currency enum — USD/EUR via ExchangeRate-API, BTC/XMR via CoinGecko —
and wires it into every func.sum(Transaction.amount) site.

Also starts a background task in the FastAPI lifespan that force-refreshes
every currency 4x/day, persisting USD to the DB and updating in-memory
caches for the rest. Failures are swallowed per-currency so a CoinGecko
outage cannot take out USD/EUR, and the last-known rate is always retained.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-04-15 17:16:20 -06:00
parent 9a80f2a997
commit 94a8a894a6
6 changed files with 222 additions and 75 deletions

View File

@@ -12,6 +12,7 @@ from app.models.models import (
TransactionSource,
TransactionType,
)
from app.services.exchange_rate import get_converted_amount_expr
MIN_YEAR = 2026
MAX_YEAR = 2030
@@ -104,13 +105,15 @@ def compute_actuals_by_source(
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
cal_start, cal_end = get_month_range(year, month)
amount_crc = get_converted_amount_expr(session)
results = {}
for source in TransactionSource:
if source == TransactionSource.CREDIT_CARD:
start, end = cc_start, cc_end
# Normal transactions in this cycle (not deferred)
compra_normal = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= start,
Transaction.date < end,
Transaction.source == source,
@@ -120,7 +123,7 @@ def compute_actuals_by_source(
).one()
# Deferred from previous cycle
compra_deferred = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == source,
@@ -131,7 +134,7 @@ def compute_actuals_by_source(
compra = float(compra_normal) + float(compra_deferred)
dev_normal = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= start,
Transaction.date < end,
Transaction.source == source,
@@ -140,7 +143,7 @@ def compute_actuals_by_source(
)
).one()
dev_deferred = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == source,
@@ -180,7 +183,7 @@ def compute_actuals_by_source(
else:
# Cash / Transfer: calendar month, no deferred logic
compra = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source == source,
@@ -188,7 +191,7 @@ def compute_actuals_by_source(
)
).one()
devolucion = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source == source,
@@ -230,6 +233,8 @@ def compute_actuals_by_category(
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
cal_start, cal_end = get_month_range(year, month)
amount_crc = get_converted_amount_expr(session)
totals: dict[int, float] = {}
def _merge_rows(rows: list) -> None:
@@ -245,7 +250,7 @@ def compute_actuals_by_category(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(Transaction.amount),
func.sum(amount_crc),
)
.where(
Transaction.date >= cc_start,
@@ -265,7 +270,7 @@ def compute_actuals_by_category(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(Transaction.amount),
func.sum(amount_crc),
)
.where(
Transaction.date >= prev_start,
@@ -285,7 +290,7 @@ def compute_actuals_by_category(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(Transaction.amount),
func.sum(amount_crc),
)
.where(
Transaction.date >= cal_start,
@@ -310,6 +315,8 @@ def compute_cc_by_category(
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
amount_crc = get_converted_amount_expr(session)
totals: dict[int | None, float] = {}
def _merge(rows: list) -> None:
@@ -325,7 +332,7 @@ def compute_cc_by_category(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(Transaction.amount),
func.sum(amount_crc),
)
.where(
Transaction.date >= cc_start,
@@ -343,7 +350,7 @@ def compute_cc_by_category(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(Transaction.amount),
func.sum(amount_crc),
)
.where(
Transaction.date >= prev_start,
@@ -449,6 +456,8 @@ def compute_monthly_projection(
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
cal_start, cal_end = get_month_range(year, month)
amount_crc = get_converted_amount_expr(session)
def _sum_uncategorized(rows: list) -> float:
total = 0.0
for tx_type, amount in rows:
@@ -461,7 +470,7 @@ def compute_monthly_projection(
# CC uncategorized: this cycle (not deferred)
uncovered_actual += _sum_uncategorized(
session.exec(
select(Transaction.transaction_type, func.sum(Transaction.amount))
select(Transaction.transaction_type, func.sum(amount_crc))
.where(
Transaction.date >= cc_start,
Transaction.date < cc_end,
@@ -476,7 +485,7 @@ def compute_monthly_projection(
# CC uncategorized: deferred from previous cycle
uncovered_actual += _sum_uncategorized(
session.exec(
select(Transaction.transaction_type, func.sum(Transaction.amount))
select(Transaction.transaction_type, func.sum(amount_crc))
.where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
@@ -491,7 +500,7 @@ def compute_monthly_projection(
# Non-CC uncategorized: calendar month
uncovered_actual += _sum_uncategorized(
session.exec(
select(Transaction.transaction_type, func.sum(Transaction.amount))
select(Transaction.transaction_type, func.sum(amount_crc))
.where(
Transaction.date >= cal_start,
Transaction.date < cal_end,