262 lines
12 KiB
HTML
262 lines
12 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Bank Connections{% endblock %}
|
|
{% block page_title %}Bank Connections{% endblock %}
|
|
|
|
{% 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;"
|
|
{% 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
|
|
</button>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
{% if enrollments %}
|
|
<div class="row g-3 mb-4">
|
|
{% for enrollment in enrollments %}
|
|
{% set accounts = enrollment.accounts.filter_by(is_active=True).all() %}
|
|
<div class="col-12 col-lg-8">
|
|
<div class="pcard">
|
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
|
<div>
|
|
<div style="font-size:15px;font-weight:600;">{{ enrollment.institution_name or 'Unknown Bank' }}</div>
|
|
<div style="font-size:12px;color:var(--muted);">
|
|
Connected {{ enrollment.created_at.strftime('%b %d, %Y') }} ·
|
|
Last synced: {{ enrollment.last_synced_at.strftime('%b %d, %H:%M') if enrollment.last_synced_at else 'Never' }}
|
|
</div>
|
|
</div>
|
|
<form method="POST" action="{{ url_for('teller.disconnect', enrollment_db_id=enrollment.id) }}"
|
|
onsubmit="return confirm('Disconnect from {{ enrollment.institution_name }}? Existing transactions are kept.')">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<button type="submit" class="btn btn-sm btn-outline-danger" style="font-size:11px;">Disconnect</button>
|
|
</form>
|
|
</div>
|
|
|
|
{% 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 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;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>
|
|
</div>
|
|
<div style="min-width:0;">
|
|
<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);">
|
|
{{ ta.account_subtype | replace('_',' ') | title }}
|
|
{% if ta.pfm_account %}
|
|
· <span style="color:var(--muted);">{{ ta.pfm_account.name }}</span>
|
|
{% else %}
|
|
· <span style="color:#f59e0b;">Not mapped</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex align-items-center gap-2 flex-shrink-0 ms-2">
|
|
{% if ta.pfm_account %}
|
|
<button id="refbtn-{{ ta.id }}"
|
|
onclick="refreshBalance({{ ta.id }}, this)"
|
|
class="btn btn-sm btn-outline-secondary" style="font-size:11px;"
|
|
title="Pull live balance from bank">
|
|
<i class="bi bi-arrow-clockwise"></i>
|
|
</button>
|
|
<a href="{{ url_for('teller.sync_preview_view', teller_account_id=ta.id) }}"
|
|
class="btn btn-sm btn-outline-primary" style="font-size:11px;">
|
|
<i class="bi bi-cloud-download me-1"></i>Sync
|
|
</a>
|
|
<form method="POST" action="{{ url_for('teller.full_resync', teller_account_id=ta.id) }}"
|
|
style="display:inline;"
|
|
onsubmit="return confirm('Reset sync cursor for {{ ta.account_name }}?\nThe next Sync will re-fetch the last 90 days. Already-imported transactions will be skipped.')">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<button type="submit" class="btn btn-sm btn-outline-warning" style="font-size:11px;" title="Re-fetch full 90-day history on next sync">
|
|
<i class="bi bi-arrow-counterclockwise"></i>
|
|
</button>
|
|
</form>
|
|
{% else %}
|
|
<a href="{{ url_for('teller.map_accounts', enrollment_id=enrollment.enrollment_id) }}"
|
|
class="btn btn-sm btn-outline-warning" style="font-size:11px;">Map Account</a>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
|
|
<!-- Sync all -->
|
|
{% if accounts | selectattr('pfm_account_id') | list %}
|
|
<div class="mt-3 text-end">
|
|
<form method="POST" action="{{ url_for('teller.sync_all') }}" style="display:inline;">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<button type="submit" class="btn btn-sm btn-primary" style="font-size:12px;">
|
|
<i class="bi bi-cloud-download me-1"></i>Sync All Accounts
|
|
</button>
|
|
</form>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% else %}
|
|
<div class="pcard text-center py-5 col-12 col-lg-6 mx-auto">
|
|
<i class="bi bi-bank2 text-muted" style="font-size:3rem;"></i>
|
|
<h5 class="mt-3 mb-1">No banks connected</h5>
|
|
<p class="text-muted small mb-3">
|
|
Connect your US bank accounts to automatically sync transactions and balances.
|
|
</p>
|
|
<button id="tellerConnectBtn2" class="btn btn-primary">
|
|
<i class="bi bi-bank me-1"></i>Connect a Bank
|
|
</button>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Setup status -->
|
|
<div class="pcard mt-2" style="background:#f8fafc;">
|
|
<div class="pcard-title mb-2">Setup Status</div>
|
|
<div style="font-size:13px;line-height:1.9;">
|
|
<div>
|
|
<i class="bi bi-{% if teller_app_id %}check-circle-fill text-success{% else %}x-circle-fill text-danger{% endif %} me-2"></i>
|
|
Teller App ID: {% if teller_app_id %}<code>{{ teller_app_id[:8] }}…</code>{% else %}<span class="text-danger">Not set — add TELLER_APP_ID to .env</span>{% endif %}
|
|
</div>
|
|
<div>
|
|
<i class="bi bi-{% if teller_cert_path %}check-circle-fill text-success{% else %}x-circle-fill text-danger{% endif %} me-2"></i>
|
|
Certificate: <code>{{ teller_cert_path or 'Not set' }}</code>
|
|
</div>
|
|
<div>
|
|
<i class="bi bi-{% if teller_key_path %}check-circle-fill text-success{% else %}x-circle-fill text-danger{% endif %} me-2"></i>
|
|
Private Key: <code>{{ teller_key_path or 'Not set' }}</code>
|
|
</div>
|
|
<div>
|
|
<i class="bi bi-info-circle text-muted me-2"></i>
|
|
Environment: <strong>{{ teller_env }}</strong>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script src="https://cdn.teller.io/connect/connect.js"></script>
|
|
<script>
|
|
const CSRF = '{{ csrf_token() }}';
|
|
|
|
function initTellerConnect(btn) {
|
|
if (!btn) return;
|
|
btn.addEventListener('click', function() {
|
|
const appId = '{{ teller_app_id }}';
|
|
if (!appId) {
|
|
alert('TELLER_APP_ID is not set in .env\nAdd it and restart the app.');
|
|
return;
|
|
}
|
|
const teller = TellerConnect.setup({
|
|
applicationId: '{{ teller_app_id }}',
|
|
environment: '{{ teller_env }}',
|
|
products: ['transactions'],
|
|
onSuccess: function(enrollment) {
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="bi bi-hourglass-split me-1"></i>Connecting…';
|
|
fetch('/teller/callback', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': CSRF,
|
|
},
|
|
// Send full Teller payload: { accessToken, user, enrollment }
|
|
body: JSON.stringify(enrollment),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.redirect) {
|
|
window.location.href = data.redirect;
|
|
} else {
|
|
alert('Connection error: ' + (data.error || 'Unknown'));
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="bi bi-bank me-1"></i>Connect a Bank';
|
|
}
|
|
})
|
|
.catch(() => {
|
|
alert('Network error. Please try again.');
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="bi bi-bank me-1"></i>Connect a Bank';
|
|
});
|
|
},
|
|
onExit: function() {
|
|
console.log('Teller Connect closed');
|
|
},
|
|
onFailure: function(error) {
|
|
alert('Connection failed: ' + error.message);
|
|
},
|
|
});
|
|
teller.open();
|
|
});
|
|
}
|
|
|
|
initTellerConnect(document.getElementById('tellerConnectBtn'));
|
|
initTellerConnect(document.getElementById('tellerConnectBtn2'));
|
|
|
|
function refreshBalance(taId, btn) {
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
|
|
|
fetch('/teller/balance/' + taId, {
|
|
method: 'POST',
|
|
headers: { 'X-CSRFToken': CSRF },
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>';
|
|
|
|
if (data.available != null) {
|
|
const sym = '{{ current_user.currency_symbol }}';
|
|
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';
|
|
setTimeout(() => { btn.style.color = ''; btn.title = 'Pull live balance from bank'; }, 3000);
|
|
} else {
|
|
btn.title = data.error || 'Refresh failed';
|
|
btn.style.color = '#ef4444';
|
|
setTimeout(() => { btn.style.color = ''; btn.title = 'Pull live balance from bank'; }, 4000);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
btn.disabled = false;
|
|
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>
|
|
{% endblock %}
|