06/05 Optimize app: implement Plaid

This commit is contained in:
2026-06-05 09:52:11 -04:00
parent 8481e9c214
commit fbfb7ab33c
15 changed files with 1612 additions and 15 deletions
+112
View File
@@ -27,6 +27,7 @@
{% for acct in accounts %}
{% set ta = teller_map.get(acct.id) %}
{% set sa = schwab_map.get(acct.id) %}
{% set pa = plaid_map.get(acct.id) %}
<div class="col-12 col-md-6 col-xl-4">
<div class="pcard" style="border-left: 4px solid {{ acct.color }};">
@@ -45,6 +46,9 @@
{% if sa %}
<span style="font-size:10px;background:#d1fae5;color:#065f46;border-radius:4px;padding:1px 5px;margin-left:4px;vertical-align:middle;">Schwab</span>
{% endif %}
{% if pa %}
<span style="font-size:10px;background:#ede9fe;color:#5b21b6;border-radius:4px;padding:1px 5px;margin-left:4px;vertical-align:middle;">Plaid</span>
{% endif %}
</div>
<div style="font-size:11px;color:var(--muted);">{{ acct.account_type | replace('_',' ') | title }}</div>
</div>
@@ -90,6 +94,35 @@
</div>
{% endif %}
<!-- Plaid credit card billing details -->
{% if pa and tab == 'credit' and (pa.cc_due_date or pa.cc_minimum_payment) %}
<div class="d-flex gap-2 mt-3">
{% if pa.cc_due_date %}
{% set days_left = (pa.cc_due_date - today).days %}
<div style="flex:1;background:#f5f3ff;border-radius:8px;padding:10px 12px;">
<div style="font-size:10px;color:#6d28d9;font-weight:600;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px;">Due Date</div>
<div style="font-size:16px;font-weight:700;color:#0f172a;">{{ pa.cc_due_date.strftime('%b %d') }}</div>
{% if days_left <= 0 %}
<div style="font-size:10px;color:#ef4444;font-weight:600;">Overdue!</div>
{% elif days_left <= 3 %}
<div style="font-size:10px;color:#f59e0b;">{{ days_left }} day{{ 's' if days_left != 1 }} left</div>
{% else %}
<div style="font-size:10px;color:var(--muted);">{{ days_left }} days left</div>
{% endif %}
</div>
{% endif %}
{% if pa.cc_minimum_payment %}
<div style="flex:1;background:#f5f3ff;border-radius:8px;padding:10px 12px;">
<div style="font-size:10px;color:#6d28d9;font-weight:600;text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px;">Min Payment</div>
<div style="font-size:16px;font-weight:700;color:#0f172a;">{{ pa.cc_minimum_payment | currency }}</div>
{% if pa.cc_last_statement_balance %}
<div style="font-size:10px;color:var(--muted);">Stmt: {{ pa.cc_last_statement_balance | currency }}</div>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
{% if acct.notes %}
<div style="font-size:12px;color:var(--muted);margin-top:10px;">{{ acct.notes }}</div>
{% endif %}
@@ -154,6 +187,37 @@
{% endif %}
{% endif %}
<!-- Plaid action buttons -->
{% if pa %}
<div class="d-flex gap-2 mt-2 pt-2" style="border-top:1px solid var(--border);">
<button onclick="refreshPlaidBalance({{ pa.id }}, {{ acct.id }}, '{{ pa.account_type }}', this)"
class="btn btn-sm btn-outline-primary flex-fill" style="font-size:11px;"
title="Pull live balance from Plaid">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
</button>
<a href="{{ url_for('plaid.sync_preview_view', item_db_id=pa.item_id) }}"
class="btn btn-sm btn-outline-primary flex-fill" style="font-size:11px;"
title="Sync new transactions from Plaid">
<i class="bi bi-cloud-download me-1"></i>Sync
</a>
{% if pa.account_type == 'credit' %}
<form method="POST" action="{{ url_for('plaid.refresh_liabilities_view', item_db_id=pa.item_id) }}" style="flex:1;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ url_for('accounts.index', tab='credit') }}">
<button type="submit" class="btn btn-sm btn-outline-secondary w-100" style="font-size:11px;"
title="Refresh due date and minimum payment from Plaid">
<i class="bi bi-credit-card me-1"></i>Billing
</button>
</form>
{% endif %}
</div>
{% if pa.last_sync_date %}
<div style="font-size:10px;color:var(--muted);margin-top:4px;text-align:right;">
Last synced {{ pa.last_sync_date.strftime('%b %d') }}
</div>
{% endif %}
{% endif %}
<!-- Schwab action buttons -->
{% if sa %}
<div class="d-flex gap-2 mt-2 pt-2" style="border-top:1px solid var(--border);">
@@ -206,6 +270,54 @@
<script>
const CSRF = document.querySelector('meta[name="csrf-token"]').content;
function refreshPlaidBalance(paId, acctId, acctType, btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>…';
fetch('/plaid/balance/' + paId, {
method: 'POST',
headers: { 'X-CSRFToken': CSRF },
})
.then(r => r.json())
.then(data => {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-clockwise me-1"></i>Refresh';
if (data.status === 'ok') {
btn.style.color = '#10b981';
btn.title = 'Balance updated just now';
setTimeout(() => { btn.style.color = ''; btn.title = 'Pull live balance from Plaid'; }, 3000);
// Update displayed balance element
const sym = '{{ current_user.currency_symbol }}';
const fmt = v => {
const n = parseFloat(v);
const abs = Math.abs(n).toLocaleString(undefined, {minimumFractionDigits:2,maximumFractionDigits:2});
return (n < 0 ? '-' : '') + sym + abs;
};
const balEl = document.getElementById('acct-bal-' + acctId);
if (balEl && data.balance != null) {
const isCc = acctType === 'credit';
if (isCc) {
const owed = Math.max(0, -parseFloat(data.balance));
balEl.textContent = (owed > 0 ? '-' : '') + fmt(owed);
} else {
balEl.textContent = fmt(data.balance);
}
balEl.style.color = '#10b981';
setTimeout(() => balEl.style.color = '', 2500);
}
} else {
btn.style.color = '#ef4444';
btn.title = data.error || 'Refresh failed';
setTimeout(() => { btn.style.color = ''; btn.title = 'Pull live balance from Plaid'; }, 4000);
}
})
.catch(() => {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-clockwise me-1"></i>Refresh';
btn.style.color = '#ef4444';
setTimeout(() => { btn.style.color = ''; }, 4000);
});
}
function refreshTellerBalance(taId, acctId, isCreditCard, btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>…';