06/01 Optimize account balances
This commit is contained in:
+46
-2
@@ -279,14 +279,58 @@ def refresh_balance(teller_account_id):
|
|||||||
try:
|
try:
|
||||||
bal_data = get_balance(ta.enrollment.access_token, ta.teller_account_id)
|
bal_data = get_balance(ta.enrollment.access_token, ta.teller_account_id)
|
||||||
available = float(bal_data.get('available') or bal_data.get('ledger') or 0)
|
available = float(bal_data.get('available') or bal_data.get('ledger') or 0)
|
||||||
|
ledger = float(bal_data.get('ledger') or bal_data.get('available') or 0)
|
||||||
ta.pfm_account.balance = available
|
ta.pfm_account.balance = available
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({'balance': available, 'status': 'ok'})
|
log.info('[teller] balance refreshed for %s: available=%.2f ledger=%.2f',
|
||||||
|
ta.account_name, available, ledger)
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'available': available,
|
||||||
|
'ledger': ledger,
|
||||||
|
'account': ta.account_name,
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error('[teller] balance refresh failed for teller_account_id=%s: %s', teller_account_id, e, exc_info=True)
|
log.error('[teller] balance refresh failed for teller_account_id=%s: %s',
|
||||||
|
teller_account_id, e, exc_info=True)
|
||||||
return jsonify({'error': str(e)}), 502
|
return jsonify({'error': str(e)}), 502
|
||||||
|
|
||||||
|
|
||||||
|
@teller_bp.route('/balance/all', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def refresh_all_balances():
|
||||||
|
"""Refresh live balances for every mapped Teller account across all enrollments."""
|
||||||
|
enrollments = TellerEnrollment.query.filter_by(is_active=True).all()
|
||||||
|
refreshed, failed = [], []
|
||||||
|
|
||||||
|
for enrollment in enrollments:
|
||||||
|
for ta in enrollment.accounts.filter_by(is_active=True).all():
|
||||||
|
if not ta.pfm_account_id:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
bal_data = get_balance(enrollment.access_token, ta.teller_account_id)
|
||||||
|
available = float(bal_data.get('available') or bal_data.get('ledger') or 0)
|
||||||
|
ta.pfm_account.balance = available
|
||||||
|
refreshed.append(ta.account_name)
|
||||||
|
log.info('[teller] balance refreshed: %s = %.2f', ta.account_name, available)
|
||||||
|
except Exception as exc:
|
||||||
|
failed.append(ta.account_name)
|
||||||
|
log.error('[teller] balance refresh failed for %s: %s',
|
||||||
|
ta.account_name, exc, exc_info=True)
|
||||||
|
|
||||||
|
if refreshed or failed:
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
if refreshed:
|
||||||
|
flash(f'Balances updated for: {", ".join(refreshed)}.', 'success')
|
||||||
|
if failed:
|
||||||
|
flash(f'Failed to refresh: {", ".join(failed)}. Check System Logs for details.', 'danger')
|
||||||
|
if not refreshed and not failed:
|
||||||
|
flash('No mapped accounts found to refresh.', 'warning')
|
||||||
|
|
||||||
|
return redirect(url_for('teller.index'))
|
||||||
|
|
||||||
|
|
||||||
# ── Disconnect ────────────────────────────────────────────────────────────────
|
# ── Disconnect ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@teller_bp.route('/disconnect/<int:enrollment_db_id>', methods=['POST'])
|
@teller_bp.route('/disconnect/<int:enrollment_db_id>', methods=['POST'])
|
||||||
|
|||||||
@@ -3,6 +3,15 @@
|
|||||||
{% block page_title %}Bank Connections{% endblock %}
|
{% block page_title %}Bank Connections{% endblock %}
|
||||||
|
|
||||||
{% block topbar_actions %}
|
{% block topbar_actions %}
|
||||||
|
{% if enrollments %}
|
||||||
|
<form method="POST" action="{{ url_for('teller.refresh_all_balances') }}" class="d-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-secondary me-1" style="font-size:12px;"
|
||||||
|
title="Pull live balances from your bank for all connected accounts">
|
||||||
|
<i class="bi bi-arrow-clockwise me-1"></i>Refresh All Balances
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
<button id="tellerConnectBtn" class="btn btn-sm btn-primary" style="font-size:12px;"
|
<button id="tellerConnectBtn" class="btn btn-sm btn-primary" style="font-size:12px;"
|
||||||
{% if not teller_app_id %}title="TELLER_APP_ID not set in .env" style="opacity:.6;"{% endif %}>
|
{% if not teller_app_id %}title="TELLER_APP_ID not set in .env" style="opacity:.6;"{% endif %}>
|
||||||
<i class="bi bi-bank me-1"></i>Connect a Bank
|
<i class="bi bi-bank me-1"></i>Connect a Bank
|
||||||
@@ -33,30 +42,44 @@
|
|||||||
|
|
||||||
{% for ta in accounts %}
|
{% for ta in accounts %}
|
||||||
<div class="d-flex justify-content-between align-items-center py-2" style="border-top:1px solid var(--border);">
|
<div class="d-flex justify-content-between align-items-center py-2" style="border-top:1px solid var(--border);">
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2" style="min-width:0;">
|
||||||
<div style="width:32px;height:32px;border-radius:8px;background:#dbeafe;color:#1e40af;display:flex;align-items:center;justify-content:center;font-size:14px;">
|
<div style="width:32px;height:32px;border-radius:8px;background:#dbeafe;color:#1e40af;display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0;">
|
||||||
<i class="bi {% if ta.account_subtype == 'credit_card' %}bi-credit-card{% elif ta.account_subtype == 'savings' %}bi-piggy-bank{% else %}bi-bank{% endif %}"></i>
|
<i class="bi {% if ta.account_subtype == 'credit_card' %}bi-credit-card{% elif ta.account_subtype == 'savings' %}bi-piggy-bank{% else %}bi-bank{% endif %}"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style="min-width:0;">
|
||||||
<div style="font-size:13px;font-weight:500;">{{ ta.account_name }}</div>
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span style="font-size:13px;font-weight:500;">{{ ta.account_name }}</span>
|
||||||
|
{% if ta.pfm_account %}
|
||||||
|
{# Balance display — updated in-place by refreshBalance() #}
|
||||||
|
<span id="bal-{{ ta.id }}" class="mono"
|
||||||
|
style="font-size:13px;font-weight:600;color:var(--text);">
|
||||||
|
{{ ta.pfm_account.balance | currency }}
|
||||||
|
</span>
|
||||||
|
{% if ta.last_sync_date %}
|
||||||
|
<span style="font-size:10px;color:var(--muted);">
|
||||||
|
synced {{ ta.last_sync_date.strftime('%b %d') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<div style="font-size:11px;color:var(--muted);">
|
<div style="font-size:11px;color:var(--muted);">
|
||||||
{{ ta.account_subtype | replace('_',' ') | title }}
|
{{ ta.account_subtype | replace('_',' ') | title }}
|
||||||
{% if ta.pfm_account %}
|
{% if ta.pfm_account %}
|
||||||
· Mapped to <strong>{{ ta.pfm_account.name }}</strong>
|
· <span style="color:var(--muted);">{{ ta.pfm_account.name }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
· <span style="color:#f59e0b;">Not mapped</span>
|
· <span style="color:#f59e0b;">Not mapped</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2 flex-shrink-0 ms-2">
|
||||||
{% if ta.pfm_account %}
|
{% if ta.pfm_account %}
|
||||||
<!-- Balance refresh -->
|
<button id="refbtn-{{ ta.id }}"
|
||||||
<button onclick="refreshBalance({{ ta.id }}, this)"
|
onclick="refreshBalance({{ ta.id }}, this)"
|
||||||
class="btn btn-sm btn-outline-secondary" style="font-size:11px;" title="Refresh live balance">
|
class="btn btn-sm btn-outline-secondary" style="font-size:11px;"
|
||||||
|
title="Pull live balance from bank">
|
||||||
<i class="bi bi-arrow-clockwise"></i>
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
</button>
|
</button>
|
||||||
<!-- Sync transactions -->
|
|
||||||
<a href="{{ url_for('teller.sync_preview_view', teller_account_id=ta.id) }}"
|
<a href="{{ url_for('teller.sync_preview_view', teller_account_id=ta.id) }}"
|
||||||
class="btn btn-sm btn-outline-primary" style="font-size:11px;">
|
class="btn btn-sm btn-outline-primary" style="font-size:11px;">
|
||||||
<i class="bi bi-cloud-download me-1"></i>Sync
|
<i class="bi bi-cloud-download me-1"></i>Sync
|
||||||
@@ -182,7 +205,8 @@ initTellerConnect(document.getElementById('tellerConnectBtn2'));
|
|||||||
|
|
||||||
function refreshBalance(taId, btn) {
|
function refreshBalance(taId, btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<i class="bi bi-hourglass-split"></i>';
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||||||
|
|
||||||
fetch('/teller/balance/' + taId, {
|
fetch('/teller/balance/' + taId, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-CSRFToken': CSRF },
|
headers: { 'X-CSRFToken': CSRF },
|
||||||
@@ -191,20 +215,38 @@ function refreshBalance(taId, btn) {
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>';
|
btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>';
|
||||||
if (data.balance != null) {
|
|
||||||
|
if (data.available != null) {
|
||||||
const sym = '{{ current_user.currency_symbol }}';
|
const sym = '{{ current_user.currency_symbol }}';
|
||||||
btn.title = 'Balance: ' + sym + parseFloat(data.balance).toLocaleString(undefined, {minimumFractionDigits:2});
|
const fmt = v => sym + parseFloat(v).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:2});
|
||||||
|
|
||||||
|
// Update inline balance display
|
||||||
|
const balEl = document.getElementById('bal-' + taId);
|
||||||
|
if (balEl) {
|
||||||
|
balEl.textContent = fmt(data.available);
|
||||||
|
balEl.style.color = '#10b981';
|
||||||
|
setTimeout(() => balEl.style.color = '', 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show available vs ledger in button tooltip
|
||||||
|
const ledgerNote = (data.ledger !== data.available)
|
||||||
|
? ` · Ledger: ${fmt(data.ledger)}`
|
||||||
|
: '';
|
||||||
|
btn.title = `Available: ${fmt(data.available)}${ledgerNote} — updated just now`;
|
||||||
btn.style.color = '#10b981';
|
btn.style.color = '#10b981';
|
||||||
setTimeout(() => btn.style.color = '', 3000);
|
setTimeout(() => { btn.style.color = ''; btn.title = 'Pull live balance from bank'; }, 3000);
|
||||||
} else {
|
} else {
|
||||||
btn.title = data.error || 'Failed';
|
btn.title = data.error || 'Refresh failed';
|
||||||
btn.style.color = '#ef4444';
|
btn.style.color = '#ef4444';
|
||||||
setTimeout(() => btn.style.color = '', 3000);
|
setTimeout(() => { btn.style.color = ''; btn.title = 'Pull live balance from bank'; }, 4000);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>';
|
btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>';
|
||||||
|
btn.title = 'Network error — try again';
|
||||||
|
btn.style.color = '#ef4444';
|
||||||
|
setTimeout(() => { btn.style.color = ''; btn.title = 'Pull live balance from bank'; }, 4000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user