familyHub/tabs/currency.php
Louis Whittington 6d10cb4726 Implement banking system features and enhancements
- Added banking mode with checking, savings, and charity accounts, including auto-split options for income.
- Introduced banking transaction management, including transfers and charity outflows.
- Updated family settings to allow configuration of banking features and interest rates.
- Enhanced data export functionality to include bank transactions.
- Improved user interface to display banking information and donation goals.
- Updated documentation to reflect new banking features and settings.
2026-03-31 11:03:53 -05:00

440 lines
29 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
require_once __DIR__ . '/../includes/db.php';
require_once __DIR__ . '/../includes/utils.php';
require_once __DIR__ . '/../includes/persona.php';
require_once __DIR__ . '/../includes/expense_helpers.php';
require_once __DIR__ . '/../includes/family_settings.php';
require_once __DIR__ . '/../includes/banking_helpers.php';
$sym = trim((string) ($familySettings['currency_symbol'] ?? '★'));
$name = trim((string) ($familySettings['currency_name'] ?? ''));
$tabTitle = currencyTabLabel($familySettings);
$bankingMode = bankingEnabled($familySettings);
$canRecordExpense = $activePerson !== null
&& ($activePerson['role'] ?? '') === ROLE_HEAD
&& isHohVerified();
$people = migrateAllPeople($people);
if ($bankingMode) {
$people = bankingApplySavingsInterestToPeople($people, $familySettings);
writeJsonFile('people.json', $people);
}
$leaderboard = $people;
usort($leaderboard, static function ($a, $b) {
$ba = is_numeric($a['checking_balance'] ?? null) ? (float) $a['checking_balance'] : (is_numeric($a['currency_balance'] ?? null) ? (float) $a['currency_balance'] : 0.0);
$bb = is_numeric($b['checking_balance'] ?? null) ? (float) $b['checking_balance'] : (is_numeric($b['currency_balance'] ?? null) ? (float) $b['currency_balance'] : 0.0);
$cmp = $bb <=> $ba;
if ($cmp !== 0) {
return $cmp;
}
return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
});
$nameById = [];
foreach ($people as $p) {
if (!empty($p['id'])) {
$nameById[(string) $p['id']] = (string) ($p['name'] ?? '');
}
}
$expenses = normalizeExpensesList(readJsonFile('expenses.json'));
usort($expenses, static function ($a, $b) {
$da = (string) ($a['date'] ?? '');
$db = (string) ($b['date'] ?? '');
if ($db !== $da) {
return strcmp($db, $da);
}
return strcmp((string) ($b['created_at'] ?? ''), (string) ($a['created_at'] ?? ''));
});
$recentExpenses = array_slice($expenses, 0, 50);
$today = gmdate('Y-m-d');
$currentMonth = gmdate('Y-m');
$bankRows = readJsonFile('bank_transactions.json');
if (!is_array($bankRows)) {
$bankRows = [];
}
usort($bankRows, static function ($a, $b) {
return strcmp((string) ($b['created_at'] ?? ''), (string) ($a['created_at'] ?? ''));
});
$recentBank = array_slice($bankRows, 0, 80);
$donatedByPersonThisMonth = [];
foreach ($bankRows as $row) {
if (!is_array($row)) {
continue;
}
if ((string) ($row['type'] ?? '') !== 'charity_outflow') {
continue;
}
$dateYmd = bankingYmd((string) ($row['created_at'] ?? ''));
if (strpos($dateYmd, $currentMonth . '-') !== 0) {
continue;
}
$pid = (string) ($row['person_id'] ?? '');
if ($pid === '') {
continue;
}
$donatedByPersonThisMonth[$pid] = ($donatedByPersonThisMonth[$pid] ?? 0) + (float) ($row['amount'] ?? 0);
}
?>
<div id="currency" class="tab-content">
<h2 class="mb-2"><?= htmlspecialchars($tabTitle, ENT_QUOTES, 'UTF-8') ?></h2>
<p class="text-muted small mb-4">Leaderboard and expenses use the family currency from Family settings.</p>
<div class="row g-4">
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header">
<i class="fa fa-trophy me-1" aria-hidden="true"></i> Leaderboard
</div>
<div class="card-body p-0">
<?php if (count($leaderboard) === 0): ?>
<p class="p-3 text-muted mb-0">Add people in Family settings to see balances.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-striped mb-0 leaderboard-table">
<thead>
<tr>
<th scope="col" class="text-muted">#</th>
<th scope="col">Name</th>
<?php if ($bankingMode): ?>
<th scope="col" class="text-end">Checking</th>
<th scope="col" class="text-end">Savings</th>
<th scope="col" class="text-end">Charity pending</th>
<th scope="col" class="text-end">Donated total</th>
<th scope="col">Donation goal progress</th>
<?php else: ?>
<th scope="col" class="text-end">Balance</th>
<?php endif; ?>
</tr>
</thead>
<tbody>
<?php foreach ($leaderboard as $rank => $p): ?>
<?php
$pid = (string) ($p['id'] ?? '');
$bal = is_numeric($p['currency_balance'] ?? null) ? (float) $p['currency_balance'] : 0.0;
$checkingBal = is_numeric($p['checking_balance'] ?? null) ? (float) $p['checking_balance'] : 0.0;
$savingsBal = is_numeric($p['savings_balance'] ?? null) ? (float) $p['savings_balance'] : 0.0;
$charityPending = is_numeric($p['charity_pending_balance'] ?? null) ? (float) $p['charity_pending_balance'] : 0.0;
$charityDonated = is_numeric($p['charity_donated_total'] ?? null) ? (float) $p['charity_donated_total'] : 0.0;
$goal = is_numeric($p['donation_goal_monthly'] ?? null) ? (float) $p['donation_goal_monthly'] : 0.0;
$donatedThisMonth = (float) ($donatedByPersonThisMonth[$pid] ?? 0);
$goalPct = $goal > 0 ? (int) max(0, min(100, round(($donatedThisMonth / $goal) * 100))) : 0;
$isSelf = $activePerson && ($activePerson['id'] ?? '') === $pid;
?>
<tr class="<?= $isSelf ? 'table-primary' : '' ?>">
<td class="text-muted"><?= (int) $rank + 1 ?></td>
<td><?= sanitizeInput($p['name'] ?? '') ?><?php if ($isSelf): ?> <span class="badge text-bg-info">You</span><?php endif; ?></td>
<?php if ($bankingMode): ?>
<td class="text-end text-nowrap"><strong><?= htmlspecialchars(number_format($checkingBal, 2, '.', ''), ENT_QUOTES, 'UTF-8') ?></strong> <?= htmlspecialchars($sym, ENT_QUOTES, 'UTF-8') ?></td>
<td class="text-end text-nowrap"><?= htmlspecialchars(number_format($savingsBal, 2, '.', ''), ENT_QUOTES, 'UTF-8') ?> <?= htmlspecialchars($sym, ENT_QUOTES, 'UTF-8') ?></td>
<td class="text-end text-nowrap"><?= htmlspecialchars(number_format($charityPending, 2, '.', ''), ENT_QUOTES, 'UTF-8') ?> <?= htmlspecialchars($sym, ENT_QUOTES, 'UTF-8') ?></td>
<td class="text-end text-nowrap"><?= htmlspecialchars(number_format($charityDonated, 2, '.', ''), ENT_QUOTES, 'UTF-8') ?> <?= htmlspecialchars($sym, ENT_QUOTES, 'UTF-8') ?></td>
<td>
<?php if ($goal > 0): ?>
<div class="progress" role="progressbar" aria-label="Donation goal progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="<?= $goalPct ?>">
<div class="progress-bar" style="width: <?= $goalPct ?>%"></div>
</div>
<div class="small text-muted mt-1"><?= htmlspecialchars(number_format($donatedThisMonth, 2, '.', ''), ENT_QUOTES, 'UTF-8') ?> / <?= htmlspecialchars(number_format($goal, 2, '.', ''), ENT_QUOTES, 'UTF-8') ?> <?= htmlspecialchars($sym, ENT_QUOTES, 'UTF-8') ?></div>
<?php else: ?>
<span class="small text-muted">No goal set</span>
<?php endif; ?>
</td>
<?php else: ?>
<td class="text-end text-nowrap"><strong><?= htmlspecialchars(number_format($bal, 2, '.', ''), ENT_QUOTES, 'UTF-8') ?></strong> <?= htmlspecialchars($sym, ENT_QUOTES, 'UTF-8') ?></td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
</div>
<div class="col-lg-7">
<?php if ($bankingMode): ?>
<div class="card border-secondary mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fa fa-arrow-right-arrow-left me-1"></i> Move your funds</span>
</div>
<div class="card-body">
<form id="bankTransferForm" class="row g-3">
<div class="col-md-4">
<label class="form-label" for="transfer_from_account">From</label>
<select class="form-select" id="transfer_from_account" required>
<option value="checking">Checking</option>
<option value="savings">Savings</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="transfer_to_account">To</label>
<select class="form-select" id="transfer_to_account" required>
<option value="savings">Savings</option>
<option value="checking">Checking</option>
<option value="charity">Charity (irreversible)</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="transfer_amount">Amount (<?= htmlspecialchars($sym, ENT_QUOTES, 'UTF-8') ?>)</label>
<input type="number" step="0.01" min="0.01" class="form-control" id="transfer_amount" required>
</div>
<div class="col-md-4">
<label class="form-label" for="transfer_category">Category</label>
<input class="form-control" id="transfer_category" maxlength="50" value="transfer">
</div>
<div class="col-md-8">
<label class="form-label" for="transfer_note">Note</label>
<input class="form-control" id="transfer_note" maxlength="200" placeholder="Optional note">
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">Submit transfer</button>
</div>
<div class="col-12">
<div class="alert d-none" id="bankTransferFeedback" role="status"></div>
</div>
</form>
</div>
</div>
<div class="card border-secondary mb-4">
<div class="card-header"><i class="fa fa-heart me-1"></i> Monthly donation goal</div>
<div class="card-body">
<form id="donationGoalForm" class="row g-3">
<?php if ($canRecordExpense): ?>
<div class="col-md-6">
<label class="form-label" for="donation_goal_person">Person</label>
<select class="form-select" id="donation_goal_person">
<?php foreach ($people as $p): ?>
<?php if (empty($p['id'])) { continue; } ?>
<option value="<?= htmlspecialchars((string) $p['id'], ENT_QUOTES, 'UTF-8') ?>" <?= (($activePerson['id'] ?? '') === ($p['id'] ?? '')) ? 'selected' : '' ?>><?= sanitizeInput($p['name'] ?? '') ?></option>
<?php endforeach; ?>
</select>
</div>
<?php else: ?>
<input type="hidden" id="donation_goal_person" value="<?= htmlspecialchars((string) ($activePerson['id'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">
<?php endif; ?>
<div class="<?= $canRecordExpense ? 'col-md-6' : 'col-md-12' ?>">
<label class="form-label" for="donation_goal_monthly">Goal for month (<?= htmlspecialchars($sym, ENT_QUOTES, 'UTF-8') ?>)</label>
<input type="number" step="0.01" min="0" class="form-control" id="donation_goal_monthly" value="<?= htmlspecialchars(number_format((float) ($activePerson['donation_goal_monthly'] ?? 0), 2, '.', ''), ENT_QUOTES, 'UTF-8') ?>">
</div>
<div class="col-12">
<button type="submit" class="btn btn-outline-primary">Save donation goal</button>
</div>
<div class="col-12">
<div class="alert d-none" id="donationGoalFeedback" role="status"></div>
</div>
</form>
</div>
</div>
<?php if ($canRecordExpense): ?>
<div class="card border-secondary mb-4">
<div class="card-header">HoH checking credit/debit</div>
<div class="card-body">
<form id="bankAdjustForm" class="row g-3">
<div class="col-md-4">
<label class="form-label" for="bank_adjust_person">Person</label>
<select class="form-select" id="bank_adjust_person" required>
<?php foreach ($people as $p): ?>
<?php if (empty($p['id'])) { continue; } ?>
<option value="<?= htmlspecialchars((string) $p['id'], ENT_QUOTES, 'UTF-8') ?>"><?= sanitizeInput($p['name'] ?? '') ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="bank_adjust_type">Type</label>
<select class="form-select" id="bank_adjust_type">
<option value="credit">Credit</option>
<option value="debit">Debit</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="bank_adjust_amount">Amount</label>
<input type="number" min="0.01" step="0.01" class="form-control" id="bank_adjust_amount" required>
</div>
<div class="col-md-4">
<label class="form-label" for="bank_adjust_category">Category</label>
<input class="form-control" id="bank_adjust_category" maxlength="50" value="manual">
</div>
<div class="col-md-8">
<label class="form-label" for="bank_adjust_note">Note</label>
<input class="form-control" id="bank_adjust_note" maxlength="200">
</div>
<div class="col-12"><button type="submit" class="btn btn-danger">Save checking transaction</button></div>
<div class="col-12"><div class="alert d-none" id="bankAdjustFeedback" role="status"></div></div>
</form>
</div>
</div>
<div class="card border-secondary mb-4">
<div class="card-header">HoH charity outflow</div>
<div class="card-body">
<form id="charityOutflowForm" class="row g-3">
<div class="col-md-4">
<label class="form-label" for="charity_outflow_person">Person</label>
<select class="form-select" id="charity_outflow_person" required>
<?php foreach ($people as $p): ?>
<?php if (empty($p['id'])) { continue; } ?>
<option value="<?= htmlspecialchars((string) $p['id'], ENT_QUOTES, 'UTF-8') ?>"><?= sanitizeInput($p['name'] ?? '') ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="charity_outflow_amount">Amount</label>
<input type="number" min="0.01" step="0.01" class="form-control" id="charity_outflow_amount" required>
</div>
<div class="col-md-4">
<label class="form-label" for="charity_outflow_category">Category</label>
<input class="form-control" id="charity_outflow_category" maxlength="50" value="donation">
</div>
<div class="col-md-12">
<label class="form-label" for="charity_outflow_note">Note</label>
<input class="form-control" id="charity_outflow_note" maxlength="200">
</div>
<div class="col-12"><button type="submit" class="btn btn-warning">Log charity outflow</button></div>
<div class="col-12"><div class="alert d-none" id="charityOutflowFeedback" role="status"></div></div>
</form>
</div>
</div>
<?php endif; ?>
<?php elseif ($canRecordExpense): ?>
<div class="card border-secondary mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fa fa-minus-circle me-1"></i> Record expense</span>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#expenseFormCollapse" aria-expanded="false" aria-controls="expenseFormCollapse">
Show form
</button>
</div>
<div class="collapse" id="expenseFormCollapse">
<div class="card-body">
<p class="small text-muted">Deducts from one persons balance (e.g. spent allowance). Requires enough balance.</p>
<form id="expenseForm" class="row g-3">
<div class="col-md-6">
<label class="form-label" for="expense_title">Title</label>
<input class="form-control" id="expense_title" required maxlength="120">
</div>
<div class="col-md-6">
<label class="form-label" for="expense_value">Amount (<?= htmlspecialchars($sym, ENT_QUOTES, 'UTF-8') ?>)</label>
<input type="number" class="form-control" id="expense_value" min="0.01" step="0.01" required>
</div>
<div class="col-md-6">
<label class="form-label" for="expense_date">Date</label>
<input type="date" class="form-control" id="expense_date" value="<?= htmlspecialchars($today, ENT_QUOTES, 'UTF-8') ?>" required>
</div>
<div class="col-md-6">
<label class="form-label" for="expense_assignee">Charged to</label>
<select class="form-select" id="expense_assignee" required>
<option value="">Choose person…</option>
<?php foreach ($people as $p): ?>
<?php if (empty($p['id'])) { continue; } ?>
<option value="<?= htmlspecialchars((string) $p['id'], ENT_QUOTES, 'UTF-8') ?>"><?= sanitizeInput($p['name'] ?? '') ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-12">
<label class="form-label" for="expense_description">Description</label>
<textarea class="form-control" id="expense_description" rows="2"></textarea>
</div>
<div class="col-12">
<button type="submit" class="btn btn-danger">Apply expense</button>
</div>
<div class="col-12">
<div class="alert d-none" id="expenseFormFeedback" role="status"></div>
</div>
</form>
</div>
</div>
</div>
<?php elseif (count($people) > 0): ?>
<p class="text-muted small">Switch to a verified Head of household to record expenses.</p>
<?php endif; ?>
<div class="card">
<div class="card-header"><?= $bankingMode ? 'Recent bank transactions' : 'Recent expenses' ?></div>
<div class="card-body p-0">
<?php if ($bankingMode && count($recentBank) === 0): ?>
<p class="p-3 text-muted mb-0">No bank transactions recorded yet.</p>
<?php elseif (!$bankingMode && count($recentExpenses) === 0): ?>
<p class="p-3 text-muted mb-0">No expenses recorded yet.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr>
<?php if ($bankingMode): ?>
<th>Date</th>
<th>Person</th>
<th>Type</th>
<th>Category</th>
<th>Note</th>
<th class="text-end">Amount</th>
<?php if ($canRecordExpense): ?><th></th><?php endif; ?>
<?php else: ?>
<th>Date</th>
<th>Title</th>
<th>To</th>
<th class="text-end">Amount</th>
<?php endif; ?>
</tr>
</thead>
<tbody>
<?php if ($bankingMode): ?>
<?php foreach ($recentBank as $row): ?>
<?php
$pid = (string) ($row['person_id'] ?? '');
$rawAmt = (float) ($row['amount'] ?? 0);
$type = (string) ($row['type'] ?? '');
$amtSign = in_array($type, ['manual_debit', 'charity_outflow'], true) ? '' : '+';
?>
<tr>
<td class="text-nowrap"><?= sanitizeInput((string) ($row['created_at'] ?? '')) ?></td>
<td><?= sanitizeInput($nameById[$pid] ?? '') ?></td>
<td><?= sanitizeInput($type) ?></td>
<td><?= sanitizeInput((string) ($row['category'] ?? '')) ?></td>
<td><?= sanitizeInput((string) ($row['note'] ?? '')) ?></td>
<td class="text-end text-nowrap"><?= $amtSign ?><?= htmlspecialchars(number_format($rawAmt, 2, '.', ''), ENT_QUOTES, 'UTF-8') ?> <?= htmlspecialchars($sym, ENT_QUOTES, 'UTF-8') ?></td>
<?php if ($canRecordExpense): ?>
<td class="text-end">
<?php if (empty($row['reversed_by_transaction_id']) && ($row['type'] ?? '') !== 'reversal'): ?>
<button type="button" class="btn btn-sm btn-outline-danger btn-bank-reverse" data-id="<?= htmlspecialchars((string) ($row['id'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">Reverse</button>
<?php endif; ?>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
<?php else: ?>
<?php foreach ($recentExpenses as $ex): ?>
<tr>
<td class="text-nowrap"><?= sanitizeInput((string) ($ex['date'] ?? '')) ?></td>
<td><?= sanitizeInput((string) ($ex['title'] ?? '')) ?></td>
<td><?= sanitizeInput($nameById[(string) ($ex['assignee_id'] ?? '')] ?? '') ?></td>
<td class="text-end text-nowrap"><?= htmlspecialchars(number_format((float) ($ex['value'] ?? 0), 2, '.', ''), ENT_QUOTES, 'UTF-8') ?> <?= htmlspecialchars($sym, ENT_QUOTES, 'UTF-8') ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
<?php if ($bankingMode): ?>
<div class="card mt-4">
<div class="card-header">Monthly statements</div>
<div class="card-body">
<p class="small text-muted mb-2">Download your monthly statement as JSON or CSV.</p>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-outline-secondary btn-sm" href="<?= htmlspecialchars($familyHubApiBase, ENT_QUOTES, 'UTF-8') ?>/bank_statement.php?month=<?= htmlspecialchars($currentMonth, ENT_QUOTES, 'UTF-8') ?>&format=json">Current month JSON</a>
<a class="btn btn-outline-secondary btn-sm" href="<?= htmlspecialchars($familyHubApiBase, ENT_QUOTES, 'UTF-8') ?>/bank_statement.php?month=<?= htmlspecialchars($currentMonth, ENT_QUOTES, 'UTF-8') ?>&format=csv">Current month CSV</a>
</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>