06/02 Integrate Schwab
This commit is contained in:
+13
-1
@@ -234,11 +234,23 @@
|
||||
>
|
||||
<i class="bi bi-wallet2"></i><span class="lt">Accounts</span>
|
||||
</a>
|
||||
<a
|
||||
href="{{ url_for('teller.index') }}"
|
||||
class="sb-link {% if request.blueprint == 'teller' %}active{% endif %}"
|
||||
>
|
||||
<i class="bi bi-bank2"></i><span class="lt">Teller Sync</span>
|
||||
</a>
|
||||
<a
|
||||
href="{{ url_for('schwab.index') }}"
|
||||
class="sb-link {% if request.blueprint == 'schwab' %}active{% endif %}"
|
||||
>
|
||||
<i class="bi bi-bank"></i><span class="lt">Schwab Sync</span>
|
||||
</a>
|
||||
<a
|
||||
href="{{ url_for('bank_import.index') }}"
|
||||
class="sb-link {% if request.blueprint == 'bank_import' %}active{% endif %}"
|
||||
>
|
||||
<i class="bi bi-bank2"></i><span class="lt">Import Statement</span>
|
||||
<i class="bi bi-upload"></i><span class="lt">Import Statement</span>
|
||||
</a>
|
||||
|
||||
<div class="sb-section">Planning</div>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Schwab Connection{% endblock %}
|
||||
{% block page_title %}Schwab Bank Sync{% endblock %}
|
||||
|
||||
{% block topbar_actions %}
|
||||
{% if connection %}
|
||||
<form method="POST" action="{{ url_for('schwab.disconnect') }}"
|
||||
onsubmit="return confirm('Disconnect Schwab? Imported transactions are kept.')" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" style="font-size:12px;">Disconnect</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if not schwab_configured %}
|
||||
<div class="pcard mb-4" style="border-left:4px solid #f59e0b;">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<i class="bi bi-exclamation-triangle-fill text-warning mt-1" style="font-size:1.4rem;flex-shrink:0;"></i>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:14px;margin-bottom:6px;">Schwab credentials not configured</div>
|
||||
<p style="font-size:13px;color:var(--muted);margin-bottom:10px;">
|
||||
Register your app at <strong>developer.schwab.com</strong>, then add these to your <code>.env</code>:
|
||||
</p>
|
||||
<pre style="font-size:12px;background:#f8fafc;padding:12px;border-radius:8px;margin:0;overflow-x:auto;">SCHWAB_CLIENT_ID=your-client-id
|
||||
SCHWAB_CLIENT_SECRET=your-client-secret
|
||||
SCHWAB_REDIRECT_URI=https://pfm.ngodanguyen.tech/schwab/callback</pre>
|
||||
<p style="font-size:12px;color:var(--muted);margin-top:8px;">
|
||||
Also register <code>https://pfm.ngodanguyen.tech/schwab/callback</code> as the redirect URI in the Schwab developer portal, then restart the app.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if connection %}
|
||||
<!-- Connected state -->
|
||||
<div class="pcard mb-4" style="border-left:4px solid #10b981;">
|
||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div style="width:42px;height:42px;border-radius:10px;background:#d1fae5;color:#065f46;display:flex;align-items:center;justify-content:center;font-size:20px;">
|
||||
<i class="bi bi-bank2"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:15px;">Charles Schwab</div>
|
||||
<div style="font-size:12px;color:var(--muted);">
|
||||
Connected {{ connection.created_at.strftime('%b %d, %Y') }} ·
|
||||
Last synced: {{ connection.last_synced_at.strftime('%b %d, %H:%M') if connection.last_synced_at else 'Never' }}
|
||||
{% if connection.token_is_expired %}
|
||||
· <span style="color:#f59e0b;"><i class="bi bi-exclamation-circle me-1"></i>Token expired — sync will auto-refresh</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('schwab.map_accounts') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">
|
||||
<i class="bi bi-diagram-2 me-1"></i>Manage Mapping
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if accounts %}
|
||||
<div class="pcard p-0">
|
||||
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-bottom:1px solid var(--border);">
|
||||
<span class="pcard-title mb-0">Accounts</span>
|
||||
</div>
|
||||
{% for sa in accounts %}
|
||||
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="{% if not loop.last %}border-bottom:1px solid var(--border);{% endif %}">
|
||||
<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 bi-bank"></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;">{{ sa.account_name }}</span>
|
||||
{% if sa.last_sync_date %}
|
||||
<span style="font-size:10px;color:var(--muted);">synced {{ sa.last_sync_date.strftime('%b %d') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--muted);">
|
||||
{{ sa.account_number_display }} · {{ sa.account_type }}
|
||||
{% if sa.pfm_account %}
|
||||
· <span style="color:var(--muted);">→ {{ sa.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 sa.pfm_account %}
|
||||
<a href="{{ url_for('schwab.sync_preview_view', schwab_account_id=sa.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('schwab.full_resync', schwab_account_id=sa.id) }}"
|
||||
style="display:inline;"
|
||||
onsubmit="return confirm('Reset sync cursor for {{ sa.account_name }}?\nNext sync re-fetches 90 days. Duplicates are 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('schwab.map_accounts') }}"
|
||||
class="btn btn-sm btn-outline-warning" style="font-size:11px;">Map Account</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="pcard text-center py-5">
|
||||
<i class="bi bi-diagram-2 text-muted" style="font-size:3rem;"></i>
|
||||
<h5 class="mt-3 mb-1">No accounts mapped</h5>
|
||||
<p class="text-muted small mb-3">Link each Schwab account to a PFM account to start syncing.</p>
|
||||
<a href="{{ url_for('schwab.map_accounts') }}" class="btn btn-primary btn-sm">Map Accounts</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<!-- Not connected -->
|
||||
<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">Connect Charles Schwab</h5>
|
||||
<p class="text-muted small mb-4">
|
||||
Sync checking, savings, and brokerage transactions directly from Schwab.
|
||||
</p>
|
||||
{% if schwab_configured %}
|
||||
<a href="{{ url_for('schwab.connect') }}" class="btn btn-primary">
|
||||
<i class="bi bi-link-45deg me-1"></i>Connect Schwab Account
|
||||
</a>
|
||||
{% else %}
|
||||
<button class="btn btn-primary" disabled>
|
||||
<i class="bi bi-link-45deg me-1"></i>Connect Schwab Account
|
||||
</button>
|
||||
<p class="text-muted small mt-2">Configure credentials first (see above)</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Map Schwab Accounts{% endblock %}
|
||||
{% block page_title %}Map Schwab Accounts{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-8">
|
||||
|
||||
<div class="pcard mb-3" style="background:#f0fdf4;border-color:#bbf7d0;">
|
||||
<div style="font-size:13px;color:#166534;">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Link each Schwab account to a PFM account, or create a new one automatically.
|
||||
Accounts set to "Skip" won't be synced.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pcard">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% for sa in schwab_accounts %}
|
||||
<div class="{% if not loop.last %}mb-4 pb-4{% endif %}" 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:32px;height:32px;border-radius:8px;background:#dbeafe;color:#1e40af;display:flex;align-items:center;justify-content:center;font-size:14px;">
|
||||
<i class="bi bi-bank"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:14px;font-weight:600;">{{ sa.account_name }}</div>
|
||||
<div style="font-size:11px;color:var(--muted);">{{ sa.account_number_display }} · {{ sa.account_type }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<select name="pfm_account_{{ sa.id }}" class="form-select form-select-sm" style="font-size:13px;">
|
||||
<option value="">— Skip this account —</option>
|
||||
<option value="new" {% if not sa.pfm_account_id %}{% endif %}>+ Create new PFM account</option>
|
||||
{% for acct in pfm_accounts %}
|
||||
<option value="{{ acct.id }}" {% if sa.pfm_account_id == acct.id %}selected{% endif %}>
|
||||
{{ acct.name }} ({{ acct.account_type | replace('_',' ') | title }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button type="submit" class="btn btn-primary">Save Mapping</button>
|
||||
<a href="{{ url_for('schwab.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,139 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Schwab Sync Preview{% endblock %}
|
||||
{% block page_title %}Sync Preview — {{ sa.account_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if preview %}
|
||||
<form method="POST" action="{{ url_for('schwab.sync_confirm') }}" id="importForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="pcard mb-3 d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||
<div>
|
||||
<div style="font-size:14px;font-weight:600;">{{ sa.account_name }} ({{ sa.account_number_display }})</div>
|
||||
<div style="font-size:12px;color:var(--muted);">
|
||||
Mapped to: <strong>{{ sa.pfm_account.name }}</strong> ·
|
||||
<span id="selectedCount">{{ count }}</span> of {{ count }} selected
|
||||
{% if sa.last_sync_date %}· Last sync: {{ sa.last_sync_date.strftime('%b %d, %Y') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('schwab.index') }}" class="btn btn-sm btn-outline-secondary" style="font-size:12px;">Cancel</a>
|
||||
<button type="submit" class="btn btn-sm btn-success" style="font-size:12px;" id="importBtn">
|
||||
<i class="bi bi-check-lg me-1"></i>Import <span id="importBtnCount">{{ count }}</span> Transaction(s)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pcard p-0">
|
||||
<table class="pfm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:36px;padding-left:16px;">
|
||||
<input type="checkbox" id="selectAll" checked style="width:15px;height:15px;cursor:pointer;">
|
||||
</th>
|
||||
<th style="white-space:nowrap;">Date</th>
|
||||
<th>Description</th>
|
||||
<th class="d-mob-none">Schwab Type</th>
|
||||
<th style="width:160px;">PFM Type</th>
|
||||
<th class="d-mob-none">Category</th>
|
||||
<th class="text-end" style="padding-right:20px;">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for txn in preview %}
|
||||
<tr class="preview-row" data-id="{{ txn.schwab_id }}">
|
||||
<td style="padding-left:16px;">
|
||||
<input type="checkbox" name="selected" value="{{ txn.schwab_id }}"
|
||||
class="row-check" checked style="width:15px;height:15px;cursor:pointer;">
|
||||
</td>
|
||||
<td style="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 class="d-mob-none" style="font-size:11px;color:var(--muted);">
|
||||
{{ txn.schwab_type | replace('_',' ') | title }}
|
||||
</td>
|
||||
<td>
|
||||
<select name="type_{{ txn.schwab_id }}" class="type-select form-select form-select-sm"
|
||||
style="width:130px;font-size:12px;border-radius:6px;
|
||||
{% if txn.transaction_type == 'income' %}border-color:#10b981;color:#10b981;{% else %}border-color:#ef4444;color:#ef4444;{% endif %}">
|
||||
<option value="expense" {% if txn.transaction_type == 'expense' %}selected{% endif %}>Expense</option>
|
||||
<option value="income" {% if txn.transaction_type == 'income' %}selected{% endif %}>Income</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="d-mob-none" style="font-size:12px;color:var(--muted);">
|
||||
{% if txn.category_id and txn.category_id in cat_map %}
|
||||
{{ cat_map[txn.category_id] }}
|
||||
{% else %}
|
||||
<span style="color:#f59e0b;">Uncategorised</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end mono amount-cell
|
||||
{% if txn.transaction_type == 'income' %}text-income{% else %}text-expense{% endif %}"
|
||||
style="font-size:13px;font-weight:600;padding-right:20px;">
|
||||
<span class="sign">{% if txn.transaction_type == 'income' %}+{% else %}-{% endif %}</span>{{ txn.amount | currency }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-check-lg me-1"></i>Confirm Import (<span id="importBtnCount2">{{ count }}</span> transactions)
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% 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('schwab.index') }}" class="btn btn-sm btn-outline-secondary">Back</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
const checks = document.querySelectorAll('.row-check');
|
||||
const countEl = document.getElementById('selectedCount');
|
||||
const btnCount = document.getElementById('importBtnCount');
|
||||
const btnCount2 = document.getElementById('importBtnCount2');
|
||||
|
||||
function updateCount() {
|
||||
const n = document.querySelectorAll('.row-check:checked').length;
|
||||
countEl.textContent = n;
|
||||
btnCount.textContent = n;
|
||||
btnCount2.textContent = n;
|
||||
document.querySelectorAll('.preview-row').forEach(row => {
|
||||
row.style.opacity = row.querySelector('.row-check').checked ? '1' : '0.4';
|
||||
});
|
||||
selectAll.indeterminate = n > 0 && n < checks.length;
|
||||
selectAll.checked = n === checks.length;
|
||||
}
|
||||
|
||||
selectAll.addEventListener('change', () => {
|
||||
checks.forEach(cb => { cb.checked = selectAll.checked; });
|
||||
updateCount();
|
||||
});
|
||||
checks.forEach(cb => cb.addEventListener('change', updateCount));
|
||||
|
||||
document.querySelectorAll('.type-select').forEach(sel => {
|
||||
sel.addEventListener('change', function () {
|
||||
const isIncome = this.value === 'income';
|
||||
this.style.borderColor = isIncome ? '#10b981' : '#ef4444';
|
||||
this.style.color = isIncome ? '#10b981' : '#ef4444';
|
||||
const row = this.closest('.preview-row');
|
||||
const cell = row.querySelector('.amount-cell');
|
||||
const sign = row.querySelector('.sign');
|
||||
cell.classList.toggle('text-income', isIncome);
|
||||
cell.classList.toggle('text-expense', !isIncome);
|
||||
sign.textContent = isIncome ? '+' : '-';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -9,10 +9,19 @@
|
||||
<a href="{{ url_for('teller.index') }}" class="text-decoration-none">
|
||||
<div class="pcard text-center py-4" style="transition:all .15s;" onmouseover="this.style.borderColor='#10b981'" onmouseout="this.style.borderColor='var(--border)'">
|
||||
<i class="bi bi-bank2" style="font-size:2rem;color:#10b981;"></i>
|
||||
<div style="font-size:14px;font-weight:600;margin-top:10px;">Bank Connections</div>
|
||||
<div style="font-size:14px;font-weight:600;margin-top:10px;">Teller Sync</div>
|
||||
<div style="font-size:12px;color:var(--muted);margin-top:4px;">Connect US bank accounts via Teller</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<a href="{{ url_for('schwab.index') }}" class="text-decoration-none">
|
||||
<div class="pcard text-center py-4" style="transition:all .15s;" onmouseover="this.style.borderColor='#3b82f6'" onmouseout="this.style.borderColor='var(--border)'">
|
||||
<i class="bi bi-bank" style="font-size:2rem;color:#3b82f6;"></i>
|
||||
<div style="font-size:14px;font-weight:600;margin-top:10px;">Schwab Sync</div>
|
||||
<div style="font-size:12px;color:var(--muted);margin-top:4px;">Connect Charles Schwab directly</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6 col-lg-3">
|
||||
<a href="{{ url_for('settings.profile') }}" class="text-decoration-none">
|
||||
|
||||
Reference in New Issue
Block a user