05/31 Teller.io connection
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Bank Connections{% endblock %}
|
||||
{% block page_title %}Bank Connections{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
<button id="tellerConnectBtn" class="btn btn-sm btn-primary" style="font-size:12px;">
|
||||
<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">
|
||||
<div style="width:32px;height:32px;border-radius:8px;background:#dbeafe;color:#1e40af;display:flex;align-items:center;justify-content:center;font-size:14px;">
|
||||
<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 style="font-size:13px;font-weight:500;">{{ ta.account_name }}</div>
|
||||
<div style="font-size:11px;color:var(--muted);">
|
||||
{{ ta.account_subtype | replace('_',' ') | title }}
|
||||
{% if ta.pfm_account %}
|
||||
· Mapped to <strong>{{ ta.pfm_account.name }}</strong>
|
||||
{% else %}
|
||||
· <span style="color:#f59e0b;">Not mapped</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{% if ta.pfm_account %}
|
||||
<!-- Balance refresh -->
|
||||
<button onclick="refreshBalance({{ ta.id }}, this)"
|
||||
class="btn btn-sm btn-outline-secondary" style="font-size:11px;" title="Refresh live balance">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
<!-- Sync transactions -->
|
||||
<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>
|
||||
{% 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 instructions -->
|
||||
<div class="pcard mt-2" style="background:#f8fafc;">
|
||||
<div class="pcard-title mb-2">Setup Requirements</div>
|
||||
<div style="font-size:13px;color:var(--muted);line-height:1.8;">
|
||||
<div><i class="bi bi-check-circle-fill text-success me-2"></i>Teller App ID set in <code>.env</code></div>
|
||||
<div><i class="bi bi-check-circle-fill text-success me-2"></i>Client certificate at <code>{{ config.TELLER_CERT_PATH }}</code></div>
|
||||
<div><i class="bi bi-check-circle-fill text-success me-2"></i>Private key at <code>{{ config.TELLER_KEY_PATH }}</code></div>
|
||||
<div><i class="bi bi-info-circle text-muted me-2"></i>Environment: <strong>{{ config.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 teller = TellerConnect.setup({
|
||||
applicationId: '{{ config.TELLER_APP_ID }}',
|
||||
environment: '{{ config.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,
|
||||
},
|
||||
body: JSON.stringify({ enrollment: 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 = '<i class="bi bi-hourglass-split"></i>';
|
||||
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.balance != null) {
|
||||
const sym = '{{ current_user.currency_symbol }}';
|
||||
btn.title = 'Balance: ' + sym + parseFloat(data.balance).toLocaleString(undefined, {minimumFractionDigits:2});
|
||||
btn.style.color = '#10b981';
|
||||
setTimeout(() => btn.style.color = '', 3000);
|
||||
} else {
|
||||
btn.title = data.error || 'Failed';
|
||||
btn.style.color = '#ef4444';
|
||||
setTimeout(() => btn.style.color = '', 3000);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-arrow-clockwise"></i>';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Map Accounts{% endblock %}
|
||||
{% block page_title %}Map Bank Accounts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-7">
|
||||
|
||||
<div class="pcard mb-3" style="background:#f0fdf4;border-color:#bbf7d0;">
|
||||
<div style="font-size:13px;color:#166534;">
|
||||
<i class="bi bi-check-circle-fill me-2"></i>
|
||||
<strong>{{ enrollment.institution_name }}</strong> connected successfully.
|
||||
Map each account below to a PFM account, or create a new one automatically.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pcard">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
{% for ta in teller_accounts %}
|
||||
<div class="mb-4 pb-4" style="{% if not loop.last %}border-bottom:1px solid var(--border);{% endif %}">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<div style="width:36px;height:36px;border-radius:9px;background:#dbeafe;color:#1e40af;display:flex;align-items:center;justify-content:center;font-size:16px;">
|
||||
<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 style="font-size:14px;font-weight:600;">{{ ta.account_name }}</div>
|
||||
<div style="font-size:11px;color:var(--muted);">
|
||||
{{ ta.institution_name }} · {{ ta.account_subtype | replace('_',' ') | title }}
|
||||
· ID: <span class="mono">{{ ta.teller_account_id[-8:] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="form-label" style="font-size:13px;font-weight:500;">Map to PFM Account</label>
|
||||
<select name="pfm_account_{{ ta.id }}" class="form-select form-select-sm">
|
||||
<option value="">— Skip this account —</option>
|
||||
<option value="new" {% if not pfm_accounts %}selected{% endif %}>
|
||||
✨ Create new account automatically
|
||||
</option>
|
||||
{% for acct in pfm_accounts %}
|
||||
<option value="{{ acct.id }}" {% if ta.pfm_account_id == acct.id %}selected{% endif %}>
|
||||
{{ acct.name }} ({{ acct.account_type | replace('_',' ') | title }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="text-muted" style="font-size:11px;">
|
||||
"Create new" auto-names the account "{{ ta.institution_name }} — {{ ta.account_name }}"
|
||||
</small>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<button type="submit" class="btn btn-primary">Save Mapping & Continue</button>
|
||||
<a href="{{ url_for('teller.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,89 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sync Preview{% endblock %}
|
||||
{% block page_title %}Sync Preview — {{ ta.account_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pcard mb-3 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<div style="font-size:14px;font-weight:600;">{{ ta.institution_name }} — {{ ta.account_name }}</div>
|
||||
<div style="font-size:12px;color:var(--muted);">
|
||||
Mapped to: <strong>{{ ta.pfm_account.name }}</strong> ·
|
||||
{{ count }} transaction(s) found
|
||||
{% if ta.last_sync_date %}· Last sync: {{ ta.last_sync_date.strftime('%b %d, %Y') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('teller.index') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">Cancel</a>
|
||||
{% if count > 0 %}
|
||||
<form method="POST" action="{{ url_for('teller.sync_confirm') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-success" style="font-size:12px;">
|
||||
<i class="bi bi-check-lg me-1"></i>Import {{ count }} Transaction(s)
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if preview %}
|
||||
<div class="pcard p-0">
|
||||
<table class="pfm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding-left:20px;">Date</th>
|
||||
<th>Description</th>
|
||||
<th>Type</th>
|
||||
<th>Category</th>
|
||||
<th class="text-end" style="padding-right:20px;">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for txn in preview %}
|
||||
<tr>
|
||||
<td style="padding-left:20px;font-size:12px;color:var(--muted);white-space:nowrap;">
|
||||
{{ txn.date.strftime('%b %d, %Y') }}
|
||||
</td>
|
||||
<td style="font-size:13px;">{{ txn.description }}</td>
|
||||
<td>
|
||||
<span class="badge {% if txn.transaction_type == 'income' %}badge-income{% else %}badge-expense{% endif %}" style="font-size:11px;">
|
||||
{{ txn.transaction_type | title }}
|
||||
</span>
|
||||
</td>
|
||||
<td style="font-size:12px;color:var(--muted);">
|
||||
{% if txn.category_id %}
|
||||
{% for cat in [txn.category_id] %}
|
||||
{# Look up category name via the category_id #}
|
||||
{{ txn.notes | replace('Teller:', '') }}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span style="color:#f59e0b;">Uncategorised</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end mono {% if txn.transaction_type == 'income' %}text-income{% else %}text-expense{% endif %}"
|
||||
style="font-size:13px;font-weight:600;padding-right:20px;">
|
||||
{% if txn.transaction_type == 'income' %}+{% else %}-{% endif %}{{ txn.amount | currency }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<form method="POST" action="{{ url_for('teller.sync_confirm') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-check-lg me-1"></i>Confirm Import ({{ count }} transactions)
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="pcard text-center py-5">
|
||||
<i class="bi bi-check-circle text-success" style="font-size:3rem;"></i>
|
||||
<h5 class="mt-3 mb-1">All up to date</h5>
|
||||
<p class="text-muted small">No new transactions found since last sync.</p>
|
||||
<a href="{{ url_for('teller.index') }}" class="btn btn-sm btn-outline-secondary">Back</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user