Files

295 lines
14 KiB
HTML

{% extends "base.html" %}
{% block title %}Plaid Bank Sync{% endblock %}
{% block page_title %}Plaid Bank Sync{% endblock %}
{% block topbar_actions %}
<a href="{{ url_for('settings.index') }}" class="btn btn-sm btn-outline-secondary me-1" style="font-size:12px;"><i class="bi bi-arrow-left me-1"></i>Settings</a>
{% endblock %}
{% block content %}
{% if not plaid_configured %}
<div class="alert alert-warning" style="font-size:13px;">
<i class="bi bi-exclamation-triangle me-2"></i>
Plaid is not configured. Add <code>PLAID_CLIENT_ID</code>, <code>PLAID_SECRET</code>, and
<code>PLAID_ENV</code> to your <code>.env</code> file and restart the app.
</div>
{% endif %}
<!-- Connect card -->
<div class="pcard mb-4">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3">
<div>
<div class="d-flex align-items-center gap-2 mb-1">
<div style="width:32px;height:32px;border-radius:8px;background:#f5f3ff;display:flex;align-items:center;justify-content:center;">
<i class="bi bi-bank2" style="color:#7c3aed;font-size:16px;"></i>
</div>
<span style="font-weight:600;font-size:15px;">Connect a Bank Account</span>
</div>
<div style="font-size:12px;color:var(--muted);">
Plaid supports 12,000+ US financial institutions — checking, savings, credit cards, and investments.
Trial accounts work in sandbox and development environments.
</div>
</div>
<button id="plaid-link-btn" class="btn btn-primary"
style="font-size:13px;min-width:150px;"
{% if not plaid_configured %}disabled{% endif %}>
<i class="bi bi-plus-lg me-1"></i>Connect Bank
</button>
</div>
</div>
<!-- Webhook status card -->
<div class="pcard mb-4" style="border-left:3px solid {% if config.get('PLAID_WEBHOOK_URL') %}#7c3aed{% else %}#94a3b8{% endif %};">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-3">
<div>
<div style="font-size:13px;font-weight:600;margin-bottom:2px;">
<i class="bi bi-bell{% if config.get('PLAID_WEBHOOK_URL') %}-fill text-primary{% else %}{% endif %} me-1"></i>
Automatic Transaction Sync (Webhooks)
</div>
{% if config.get('PLAID_WEBHOOK_URL') %}
<div style="font-size:11px;color:var(--muted);">
Webhook URL: <code style="font-size:11px;">{{ config.get('PLAID_WEBHOOK_URL') }}</code>
— Plaid will push transaction updates automatically.
</div>
{% else %}
<div style="font-size:11px;color:var(--muted);">
Not configured. Set <code>PLAID_WEBHOOK_URL=https://pfm.ngodanguyen.tech/plaid/webhook</code>
in <code>.env</code> and restart. New connections will register it automatically.
For existing items, click "Apply to Existing" after setting the URL.
</div>
{% endif %}
</div>
{% if config.get('PLAID_WEBHOOK_URL') and items %}
<form method="POST" action="{{ url_for('plaid.update_webhook') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-primary" style="font-size:12px;"
title="Register the configured PLAID_WEBHOOK_URL with all connected Plaid items">
<i class="bi bi-arrow-repeat me-1"></i>Apply to Existing Items
</button>
</form>
{% endif %}
</div>
</div>
{% if items %}
{% for item in items %}
<div class="pcard mb-3">
<!-- Item header -->
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<div style="font-size:15px;font-weight:600;">{{ item.institution_name }}</div>
<div style="font-size:11px;color:var(--muted);">
{{ item.accounts.filter_by(is_active=True).count() }} account(s) connected
{% if item.last_synced_at %}· Last synced {{ item.last_synced_at.strftime('%b %d, %Y %H:%M') }}{% endif %}
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<a href="{{ url_for('plaid.sync_preview_view', item_db_id=item.id) }}"
class="btn btn-sm btn-outline-primary" style="font-size:12px;">
<i class="bi bi-cloud-download me-1"></i>Sync Transactions
</a>
<form method="POST" action="{{ url_for('plaid.refresh_liabilities_view', item_db_id=item.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ url_for('plaid.index') }}">
<button type="submit" class="btn btn-sm btn-outline-secondary" style="font-size:12px;"
title="Refresh credit card due dates and minimum payments">
<i class="bi bi-credit-card me-1"></i>Billing Details
</button>
</form>
<form method="POST" action="{{ url_for('plaid.full_resync', item_db_id=item.id) }}"
onsubmit="return confirm('Reset sync cursor for {{ item.institution_name }}?\nThe next sync will re-fetch full available history. Duplicate transactions will be skipped automatically.')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-warning" style="font-size:12px;">
<i class="bi bi-arrow-counterclockwise me-1"></i>Reset
</button>
</form>
<form method="POST" action="{{ url_for('plaid.disconnect', item_db_id=item.id) }}"
onsubmit="return confirm('Disconnect {{ item.institution_name }}?\nImported transactions will be kept.')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger" style="font-size:12px;">
<i class="bi bi-x-lg me-1"></i>Disconnect
</button>
</form>
</div>
</div>
<!-- Accounts table -->
{% set accts = item.accounts.filter_by(is_active=True).all() %}
{% if accts %}
<div style="overflow-x:auto;">
<table class="pfm-table mb-0">
<thead>
<tr>
<th>Account</th>
<th>Type</th>
<th>Mapped to</th>
<th>Last Sync</th>
<th>Credit Card Billing</th>
<th style="text-align:right;padding-right:12px;">Actions</th>
</tr>
</thead>
<tbody>
{% for pa in accts %}
<tr>
<td>
<div style="font-size:13px;font-weight:500;">{{ pa.display_name }}</div>
<div style="font-size:11px;color:var(--muted);">{{ pa.account_type | title }} · {{ pa.account_subtype | replace('_',' ') | title }}</div>
</td>
<td>
<span class="badge" style="font-size:10px;
{% if pa.account_type == 'credit' %}background:#fee2e2;color:#991b1b;
{% elif pa.account_type == 'depository' %}background:#dbeafe;color:#1e40af;
{% else %}background:#f1f5f9;color:#475569;{% endif %}">
{{ pa.account_type | title }}
</span>
</td>
<td style="font-size:12px;">
{% if pa.pfm_account %}
<span style="color:#10b981;font-weight:500;">{{ pa.pfm_account.name }}</span>
{% else %}
<a href="{{ url_for('plaid.map_accounts', item_db_id=item.id) }}"
style="font-size:11px;color:#f59e0b;">Map Account</a>
{% endif %}
</td>
<td style="font-size:11px;color:var(--muted);">
{{ pa.last_sync_date.strftime('%b %d') if pa.last_sync_date else '—' }}
</td>
<td style="font-size:11px;">
{% if pa.account_type == 'credit' %}
{% if pa.cc_due_date %}
{% set days_left = (pa.cc_due_date - today).days %}
<div>
<span style="color:#6d28d9;font-weight:600;">Due {{ pa.cc_due_date.strftime('%b %d') }}</span>
{% if days_left <= 0 %}
<span style="color:#ef4444;font-size:10px;"> · Overdue!</span>
{% elif days_left <= 3 %}
<span style="color:#f59e0b;font-size:10px;"> · {{ days_left }}d left</span>
{% else %}
<span style="color:var(--muted);font-size:10px;"> · {{ days_left }}d</span>
{% endif %}
</div>
{% if pa.cc_minimum_payment %}
<div style="color:var(--muted);">Min: {{ pa.cc_minimum_payment | currency }}</div>
{% endif %}
{% else %}
<span style="color:var(--muted);"></span>
{% endif %}
{% else %}
<span style="color:var(--muted);">N/A</span>
{% endif %}
</td>
<td style="text-align:right;padding-right:12px;">
{% if pa.pfm_account_id %}
<button onclick="refreshPlaidBalance({{ pa.id }}, {{ pa.pfm_account_id }}, '{{ pa.account_type }}', this)"
class="btn btn-sm btn-outline-secondary" style="font-size:11px;">
<i class="bi bi-arrow-clockwise me-1"></i>Balance
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="pcard text-center py-5 text-muted">
<i class="bi bi-bank" style="font-size:3rem;opacity:.25;display:block;margin-bottom:16px;"></i>
<div style="font-size:14px;font-weight:500;margin-bottom:4px;">No banks connected yet</div>
<div style="font-size:12px;">Click "Connect Bank" to link your first account via Plaid.</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script src="https://cdn.plaid.com/link/v2/stable/link-initialize.js"></script>
<script>
(function () {
const CSRF = document.querySelector('meta[name="csrf-token"]').content;
const linkBtn = document.getElementById('plaid-link-btn');
linkBtn && linkBtn.addEventListener('click', async () => {
linkBtn.disabled = true;
linkBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Loading…';
try {
const r = await fetch('{{ url_for("plaid.create_link_token_view") }}',
{ method: 'POST', headers: { 'X-CSRFToken': CSRF } });
const data = await r.json();
if (!r.ok || data.error) throw new Error(data.error || 'Failed to create link token');
const handler = Plaid.create({
token: data.link_token,
onSuccess: async (publicToken, metadata) => {
linkBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Connecting…';
const resp = await fetch('{{ url_for("plaid.exchange_token") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF },
body: JSON.stringify({
public_token: publicToken,
institution_id: metadata.institution?.institution_id || '',
institution_name: metadata.institution?.name || 'Unknown Bank',
accounts: metadata.accounts || [],
}),
});
const result = await resp.json();
if (result.error) {
alert('Error: ' + result.error);
resetBtn();
} else {
window.location.href = result.redirect;
}
},
onExit: (err) => {
if (err) console.error('[Plaid] Link exit with error:', err);
resetBtn();
},
});
handler.open();
} catch (err) {
alert('Could not open Plaid Link: ' + err.message);
resetBtn();
}
function resetBtn() {
linkBtn.disabled = false;
linkBtn.innerHTML = '<i class="bi bi-plus-lg me-1"></i>Connect Bank';
}
});
// Balance refresh (AJAX)
window.refreshPlaidBalance = function (paId, acctId, acctType, btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></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>Balance';
if (data.error) {
btn.title = data.error;
btn.style.color = '#ef4444';
setTimeout(() => { btn.style.color = ''; btn.title = ''; }, 4000);
} else {
btn.style.color = '#10b981';
btn.title = 'Updated just now';
setTimeout(() => { btn.style.color = ''; btn.title = ''; }, 3000);
}
})
.catch(() => {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-clockwise me-1"></i>Balance';
btn.style.color = '#ef4444';
setTimeout(() => { btn.style.color = ''; }, 4000);
});
};
})();
</script>
{% endblock %}