Initial commit — Moon Household Budget

This commit is contained in:
Bonna
2026-06-06 14:22:57 -05:00
commit fa3c030b36
44 changed files with 10860 additions and 0 deletions

396
templates/review.html Normal file
View File

@@ -0,0 +1,396 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>Review Transactions</h1>
{% if remaining %}<span class="badge">{{ remaining }} remaining</span>{% endif %}
</div>
{% if remaining %}
<div style="background:var(--warm-white);border:1px solid var(--border);border-radius:var(--radius);padding:0.85rem 1.25rem;margin-bottom:1.25rem;display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap;">
<span style="font-size:0.88rem;color:var(--text-mid);">Need to bulk-clear old transactions?</span>
<form method="POST" action="{{ url_for('bulk_ignore_before') }}" onsubmit="return confirm('Ignore all unreviewed transactions before this date?')">
<div style="display:flex;align-items:center;gap:0.5rem;">
<label style="font-size:0.85rem;">Ignore everything before</label>
<input type="date" name="before_date" required class="budget-input" style="width:150px;">
<button type="submit" class="btn btn-ghost" style="color:var(--text-light);font-size:0.85rem;">Bulk ignore</button>
</div>
</form>
</div>
{% endif %}
{% if not transaction %}
<div class="empty-state card-wide">
<div class="empty-icon"></div>
<h2>All caught up!</h2>
<p>Every transaction has been reviewed.</p>
<a href="{{ url_for('transactions_view') }}" class="btn btn-primary">View All Transactions</a>
</div>
{% else %}
<div class="review-card">
<div class="review-meta">
<span class="review-date">{{ transaction.date }}</span>
<span class="review-account">{{ transaction.account }}</span>
</div>
<div class="review-description">{{ transaction.description }}</div>
<div class="review-amount">${{ transaction.amount | money }}</div>
{% if bnpl_lender and bnpl_matches %}
<div style="background:#f0f5ff;border:1.5px solid #b0c4ef;border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1rem;">
<div style="font-weight:600;margin-bottom:0.5rem;">💳 Looks like a {{ bnpl_lender | title }} payment — which loan is this?</div>
<div style="display:flex;flex-direction:column;gap:0.5rem;">
{% for loan in bnpl_matches %}
<form method="POST" action="{{ url_for('match_bnpl') }}">
<input type="hidden" name="txn_id" value="{{ transaction.id }}">
<input type="hidden" name="debt_id" value="{{ loan.id }}">
<input type="hidden" name="category" value="Debt">
<button type="submit" style="width:100%;text-align:left;background:{% if loan.amount_match %}#e8f0fe{% else %}#fff{% endif %};border:1px solid {% if loan.amount_match %}#7aacf0{% else %}var(--border){% endif %};border-radius:6px;padding:0.6rem 0.85rem;cursor:pointer;display:flex;justify-content:space-between;align-items:center;gap:1rem;">
<span>
<strong>{{ loan.name }}</strong>
<span style="font-size:0.78rem;color:var(--text-light);margin-left:0.5rem;">{{ loan.payments_made }}/{{ loan.total_payments }} payments made</span>
</span>
<span style="white-space:nowrap;">
${{ loan.payment_amount | money }}
{% if loan.amount_match %}<span style="color:#5A9E68;font-size:0.78rem;margin-left:0.4rem;">✓ amount matches</span>{% endif %}
</span>
</button>
</form>
{% endfor %}
</div>
<div style="margin-top:0.6rem;font-size:0.8rem;color:var(--text-light);">Selecting a loan will mark one payment as made and categorize this as Debt. Or ignore this and categorize manually below.</div>
</div>
{% elif bnpl_lender %}
<div style="background:#fff8e6;border:1px solid #e8d49a;border-radius:var(--radius);padding:1rem 1.25rem;margin-bottom:1rem;">
<div style="font-weight:600;margin-bottom:0.25rem;">💳 Looks like a {{ bnpl_lender | title }} payment — no active loans found</div>
<div style="font-size:0.83rem;color:var(--text-light);margin-bottom:0.75rem;">Want to set up this loan now so future payments get tracked automatically?</div>
<button type="button" class="btn btn-ghost" style="font-size:0.85rem;" onclick="document.getElementById('bnpl-quick-add').style.display='block';this.style.display='none';">
+ Add {{ bnpl_lender | title }} loan now
</button>
<div id="bnpl-quick-add" style="display:none;margin-top:0.75rem;">
<form method="POST" action="{{ url_for('bnpl_add') }}">
<input type="hidden" name="bnpl_provider" value="{{ bnpl_lender | title }}">
<input type="hidden" name="redirect_to" value="review">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;margin-bottom:0.75rem;">
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">What did you buy?</label>
<input type="text" name="description" class="budget-input" style="width:100%;" placeholder="e.g. Nike Shoes" required>
</div>
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Merchant (optional)</label>
<input type="text" name="merchant" class="budget-input" style="width:100%;" placeholder="e.g. Nike">
</div>
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Payment Amount</label>
<input type="number" name="payment_amount" step="0.01" min="0" class="budget-input" style="width:100%;" value="{{ transaction.amount }}" required>
</div>
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Total # of Payments</label>
<input type="number" name="total_payments" min="1" class="budget-input" style="width:100%;" placeholder="e.g. 4" required>
</div>
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Frequency</label>
<select name="frequency" class="budget-input" style="width:100%;">
<option value="biweekly">Every 2 weeks</option>
<option value="monthly">Monthly</option>
<option value="weekly">Weekly</option>
</select>
</div>
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Next Payment Date</label>
<input type="date" name="next_payment_date" class="budget-input" style="width:100%;">
</div>
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Who's it for?</label>
<select name="owner" class="budget-input" style="width:100%;">
<option value="Shared">Shared</option>
<option value="Bonna">Bonna</option>
<option value="Tony">Tony</option>
</select>
</div>
<div>
<label style="font-size:0.8rem;display:block;margin-bottom:0.2rem;">Payments already made</label>
<input type="number" name="payments_made" min="0" value="1" class="budget-input" style="width:100%;">
</div>
</div>
<div style="display:flex;gap:0.75rem;align-items:center;">
<button type="submit" class="btn btn-primary">Save Loan & Continue</button>
<button type="button" class="btn btn-ghost" onclick="document.getElementById('bnpl-quick-add').style.display='none';this.closest('div').previousElementSibling.style.display='inline-block';">Cancel</button>
<span style="font-size:0.78rem;color:var(--text-light);">This transaction will still need to be categorized on the next screen</span>
</div>
</form>
</div>
</div>
{% endif %}
{% if suggestion %}
<div class="suggestion-banner">
<span>Suggested: <strong>{{ suggestion.category }}{% if suggestion.subcategory %} {{ suggestion.subcategory }}{% endif %}</strong></span>
<form method="POST" action="{{ url_for('categorize') }}" style="display:inline;">
<input type="hidden" name="id" value="{{ transaction.id }}">
<input type="hidden" name="category" value="{{ suggestion.category }}">
<input type="hidden" name="subcategory" value="{{ suggestion.subcategory or '' }}">
<button type="submit" class="btn btn-accept">Accept</button>
</form>
</div>
{% endif %}
<form method="POST" action="{{ url_for('categorize') }}" class="categorize-form">
<input type="hidden" name="id" value="{{ transaction.id }}">
<div class="form-group">
<label>Category</label>
<div class="category-grid">
{% for cat, subcats in categories.items() %}
<label class="cat-option" style="--cat-color: {{ colors.get(cat, '#ddd') }};">
<input type="radio" name="category" value="{{ cat }}"
{% if suggestion and suggestion.category == cat %}checked{% endif %}
onchange="updateSubcats(this.value)">
<span>{{ cat }}</span>
</label>
{% endfor %}
</div>
</div>
<div class="form-group" id="subcat-group">
<label>Subcategory <span class="optional">(optional)</span></label>
<select name="subcategory" id="subcat-select">
<option value="">— none —</option>
</select>
</div>
<!-- Sinking fund picker — shown when Transfer > Sinking Fund is selected -->
<div class="form-group" id="sinking-fund-prompt" style="display:none;">
<label>Which fund? <span class="optional">(will auto-add to that fund's balance)</span></label>
<select name="sinking_fund_name" id="sinking-fund-select" class="budget-input">
<option value="">— select fund —</option>
{% for f in sinking_funds %}
<option value="{{ f.name }}">{{ f.name }} (current: ${{ (f.balance or 0) | money }})</option>
{% endfor %}
</select>
</div>
<!-- Car prompt — shown when Gas subcategory is selected -->
<div class="form-group" id="car-prompt" style="display:none;">
<label>Which car? <span class="optional">(will auto-log to that car's profile)</span></label>
<select name="car_id" id="car-select">
<option value="">— select car —</option>
{% for car in personal_cars %}
<option value="{{ car.id }}">{{ car.display_name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Notes <span class="optional">(optional)</span></label>
<input type="text" name="notes" placeholder="e.g. Costco run, birthday gift...">
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" name="remember" checked>
Remember this for future transactions from this merchant
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save & Next</button>
<button type="button" class="btn btn-ghost" onclick="toggleSplit()">Split transaction</button>
</div>
</form>
<!-- Quick-action buttons — must be outside the main form -->
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-top:0.75rem;">
<form method="POST" action="{{ url_for('ignore_transaction') }}">
<input type="hidden" name="id" value="{{ transaction.id }}">
<button type="submit" class="btn btn-ghost" style="color:var(--text-light);font-size:0.82rem;"
title="Marks as reviewed but excludes from all totals — use for bill payments, debt payments, income deposits">
✓ Already tracked — ignore
</button>
</form>
<button type="button" class="btn btn-ghost" style="background:#e8f5e9;color:#3a7d44;border:1px solid #a5d6a7;font-size:0.82rem;" onclick="document.getElementById('review-sinking-picker').style.display='block';this.style.display='none';">🏦 Sinking Fund Transfer</button>
<div id="review-sinking-picker" style="display:none;background:#e8f5e9;border:1px solid #a5d6a7;border-radius:var(--radius);padding:0.75rem 1rem;width:100%;">
<form method="POST" action="{{ url_for('categorize_sinking_transfer') }}">
<input type="hidden" name="id" value="{{ transaction.id }}">
<input type="hidden" name="redirect" value="review">
<label style="font-size:0.82rem;font-weight:600;display:block;margin-bottom:0.4rem;">Which sinking fund?</label>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
<select name="sinking_fund_name" class="budget-input" style="min-width:180px;" required>
<option value="">— select fund —</option>
{% for f in sinking_funds %}
<option value="{{ f.name }}">{{ f.name }} (${{ (f.balance or 0) | money }})</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;">Save & Next</button>
<button type="button" class="btn btn-ghost" style="font-size:0.82rem;" onclick="document.getElementById('review-sinking-picker').style.display='none';this.closest('div').previousElementSibling.style.display='inline-block';">Cancel</button>
</div>
</form>
</div>
<form method="POST" action="{{ url_for('ignore_transaction') }}">
<input type="hidden" name="id" value="{{ transaction.id }}">
<input type="hidden" name="label" value="Investment Transfer">
<button type="submit" class="btn btn-ghost" style="background:#e3f2fd;color:#1565c0;border:1px solid #90caf9;font-size:0.82rem;">📈 Investment Transfer</button>
</form>
<form method="POST" action="{{ url_for('ignore_transaction') }}">
<input type="hidden" name="id" value="{{ transaction.id }}">
<input type="hidden" name="label" value="Savings Transfer">
<button type="submit" class="btn btn-ghost" style="background:#f3e5f5;color:#6a1b9a;border:1px solid #ce93d8;font-size:0.82rem;">💰 Savings Transfer</button>
</form>
<form method="POST" action="{{ url_for('skip') }}">
<input type="hidden" name="id" value="{{ transaction.id }}">
<button type="submit" class="btn btn-ghost" style="font-size:0.82rem;">Skip for now</button>
</form>
</div>
<!-- Split transaction form -->
<div id="split-panel" style="display:none;margin-top:1.5rem;">
<div class="budget-section">
<div class="budget-section-header" style="border-left-color:#D4A96A;">
<h3>Split Transaction — ${{ transaction.amount | money }} total</h3>
</div>
<p class="muted" style="margin-bottom:1rem;font-size:0.85rem;">Divide this charge across multiple categories. Amounts must add up to ${{ transaction.amount | money }}.</p>
<form method="POST" action="{{ url_for('split_transaction') }}">
<input type="hidden" name="id" value="{{ transaction.id }}">
<table class="budget-table" id="split-table">
<thead><tr><th>Category</th><th>Subcategory</th><th>Amount</th><th>Note</th><th></th></tr></thead>
<tbody id="split-rows">
<tr class="split-row">
<td>
<select name="split_cat_0" class="budget-input split-cat" onchange="updateSplitSubcat(this, 0)" style="width:130px;">
<option value="">— pick —</option>
{% for cat in categories %}<option value="{{ cat }}">{{ cat }}</option>{% endfor %}
</select>
</td>
<td><select name="split_subcat_0" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
<td><input type="number" name="split_amt_0" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
<td><input type="text" name="split_note_0" class="budget-input" style="width:110px;" placeholder="optional"></td>
<td></td>
</tr>
<tr class="split-row">
<td>
<select name="split_cat_1" class="budget-input split-cat" onchange="updateSplitSubcat(this, 1)" style="width:130px;">
<option value="">— pick —</option>
{% for cat in categories %}<option value="{{ cat }}">{{ cat }}</option>{% endfor %}
</select>
</td>
<td><select name="split_subcat_1" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
<td><input type="number" name="split_amt_1" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
<td><input type="text" name="split_note_1" class="budget-input" style="width:110px;" placeholder="optional"></td>
<td></td>
</tr>
</tbody>
</table>
<div style="margin:0.75rem 0;font-size:0.85rem;">
Remaining: <strong id="split-remaining">${{ transaction.amount | money }}</strong>
<button type="button" class="btn btn-ghost" style="margin-left:1rem;font-size:0.78rem;" onclick="addSplitRow()">+ Add row</button>
</div>
<div style="display:flex;gap:0.75rem;">
<button type="submit" class="btn btn-primary">Save Split & Next</button>
<button type="button" class="btn btn-ghost" onclick="toggleSplit()">Cancel</button>
</div>
</form>
</div>
</div>
</div>
<script>
const subcats = {{ categories | tojson }};
const suggestion = {{ suggestion | tojson if suggestion else 'null' }};
function updateSubcats(cat) {
const select = document.getElementById('subcat-select');
const group = document.getElementById('subcat-group');
const list = subcats[cat] || [];
select.innerHTML = '<option value="">— none —</option>';
list.forEach(s => {
const opt = document.createElement('option');
opt.value = s;
opt.textContent = s;
if (suggestion && suggestion.subcategory === s) opt.selected = true;
select.appendChild(opt);
});
group.style.display = list.length ? 'block' : 'none';
checkCarPrompt(cat, select.value);
}
function checkCarPrompt(cat, subcat) {
const carPrompt = document.getElementById('car-prompt');
if (cat === 'Car' && subcat === 'Gas') {
carPrompt.style.display = 'block';
} else {
carPrompt.style.display = 'none';
document.getElementById('car-select').value = '';
}
const sinkingPrompt = document.getElementById('sinking-fund-prompt');
if (cat === 'Transfer' && subcat === 'Sinking Fund') {
sinkingPrompt.style.display = 'block';
} else {
sinkingPrompt.style.display = 'none';
document.getElementById('sinking-fund-select').value = '';
}
}
document.getElementById('subcat-select').addEventListener('change', function() {
const cat = document.querySelector('input[name="category"]:checked');
checkCarPrompt(cat ? cat.value : '', this.value);
});
// Initialize on load
const checked = document.querySelector('input[name="category"]:checked');
if (checked) updateSubcats(checked.value);
document.querySelectorAll('input[name="category"]').forEach(r => {
r.addEventListener('change', () => updateSubcats(r.value));
});
// Split transaction
const totalAmount = {{ transaction.amount }};
const allSubcats = {{ categories | tojson }};
let splitRowCount = 2;
function toggleSplit() {
const panel = document.getElementById('split-panel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
}
function updateSplitSubcat(selectEl, idx) {
const cat = selectEl.value;
const subcatSel = document.querySelector(`select[name="split_subcat_${idx}"]`);
const list = allSubcats[cat] || [];
subcatSel.innerHTML = '<option value="">— none —</option>';
list.forEach(s => {
const opt = document.createElement('option');
opt.value = s; opt.textContent = s;
subcatSel.appendChild(opt);
});
}
function updateRemaining() {
const inputs = document.querySelectorAll('.split-amt');
let used = 0;
inputs.forEach(el => used += parseFloat(el.value) || 0);
const remaining = Math.round((totalAmount - used) * 100) / 100;
const el = document.getElementById('split-remaining');
el.textContent = '$' + remaining.toFixed(2);
el.style.color = Math.abs(remaining) < 0.01 ? '#5A9E68' : (remaining < 0 ? '#c07070' : 'inherit');
}
function addSplitRow() {
const idx = splitRowCount++;
const tbody = document.getElementById('split-rows');
const row = document.createElement('tr');
row.className = 'split-row';
row.innerHTML = `
<td><select name="split_cat_${idx}" class="budget-input split-cat" onchange="updateSplitSubcat(this,${idx})" style="width:130px;">
<option value="">— pick —</option>
${Object.keys(allSubcats).map(c => `<option value="${c}">${c}</option>`).join('')}
</select></td>
<td><select name="split_subcat_${idx}" class="budget-input split-subcat" style="width:130px;"><option value="">— none —</option></select></td>
<td><input type="number" name="split_amt_${idx}" step="0.01" min="0" class="budget-input split-amt" style="width:90px;" placeholder="0.00" oninput="updateRemaining()"></td>
<td><input type="text" name="split_note_${idx}" class="budget-input" style="width:110px;" placeholder="optional"></td>
<td><button type="button" onclick="this.closest('tr').remove();updateRemaining();" style="background:none;border:none;color:#c07070;cursor:pointer;font-size:1rem;">✕</button></td>
`;
tbody.appendChild(row);
}
</script>
{% endif %}
{% endblock %}