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.
This commit is contained in:
2026-03-31 11:03:53 -05:00
parent f14de0b7e1
commit 6d10cb4726
21 changed files with 1337 additions and 41 deletions
+280 -19
View File
@@ -4,19 +4,27 @@ 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['currency_balance'] ?? null) ? (float) $a['currency_balance'] : 0.0;
$bb = is_numeric($b['currency_balance'] ?? null) ? (float) $b['currency_balance'] : 0.0;
$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;
@@ -43,6 +51,33 @@ usort($expenses, static function ($a, $b) {
$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">
@@ -65,7 +100,15 @@ $today = gmdate('Y-m-d');
<tr>
<th scope="col" class="text-muted">#</th>
<th scope="col">Name</th>
<th scope="col" class="text-end">Balance</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>
@@ -73,12 +116,36 @@ $today = gmdate('Y-m-d');
<?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>
<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 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>
@@ -90,7 +157,151 @@ $today = gmdate('Y-m-d');
</div>
<div class="col-lg-7">
<?php if ($canRecordExpense): ?>
<?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>
@@ -143,36 +354,86 @@ $today = gmdate('Y-m-d');
<?php endif; ?>
<div class="card">
<div class="card-header">Recent expenses</div>
<div class="card-header"><?= $bankingMode ? 'Recent bank transactions' : 'Recent expenses' ?></div>
<div class="card-body p-0">
<?php if (count($recentExpenses) === 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>
<th>Date</th>
<th>Title</th>
<th>To</th>
<th class="text-end">Amount</th>
<?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 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 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>
+47
View File
@@ -164,6 +164,53 @@ $permanenceOptions = [
value="<?= (int) ($familySettings['week_starts_on'] ?? 0) ?>"
<?= !$canManage ? 'disabled' : '' ?>>
</div>
<div class="col-12"><hr class="my-2"></div>
<div class="col-12">
<h3 class="h6 mb-2">Banking system (optional)</h3>
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="banking_enabled" name="banking_enabled" value="1"
<?= !empty($familySettings['banking_enabled']) ? 'checked' : '' ?>
<?= !$canManage ? 'disabled' : '' ?>>
<label class="form-check-label" for="banking_enabled">Enable checking/savings/charity accounts</label>
</div>
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="banking_auto_split_enabled" name="banking_auto_split_enabled" value="1"
<?= !empty($familySettings['banking_auto_split_enabled']) ? 'checked' : '' ?>
<?= !$canManage ? 'disabled' : '' ?>>
<label class="form-check-label" for="banking_auto_split_enabled">Auto split all checking income</label>
</div>
</div>
<div class="col-md-4">
<label class="form-label" for="banking_roundup_destination">Round-up rule</label>
<select class="form-select" id="banking_roundup_destination" name="banking_roundup_destination" <?= !$canManage ? 'disabled' : '' ?>>
<?php $roundupDest = (string) ($familySettings['banking_roundup_destination'] ?? 'off'); ?>
<option value="off" <?= $roundupDest === 'off' ? 'selected' : '' ?>>Off</option>
<option value="savings" <?= $roundupDest === 'savings' ? 'selected' : '' ?>>Nearest whole to savings</option>
<option value="charity" <?= $roundupDest === 'charity' ? 'selected' : '' ?>>Nearest whole to charity</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="banking_auto_split_savings_pct">Auto split to savings (%)</label>
<input type="number" step="0.01" min="0" max="100" class="form-control" id="banking_auto_split_savings_pct" name="banking_auto_split_savings_pct"
value="<?= htmlspecialchars(number_format((float) ($familySettings['banking_auto_split_savings_pct'] ?? 0), 2, '.', ''), ENT_QUOTES, 'UTF-8') ?>"
<?= !$canManage ? 'disabled' : '' ?>>
</div>
<div class="col-md-4">
<label class="form-label" for="banking_auto_split_charity_pct">Auto split to charity (%)</label>
<input type="number" step="0.01" min="0" max="100" class="form-control" id="banking_auto_split_charity_pct" name="banking_auto_split_charity_pct"
value="<?= htmlspecialchars(number_format((float) ($familySettings['banking_auto_split_charity_pct'] ?? 0), 2, '.', ''), ENT_QUOTES, 'UTF-8') ?>"
<?= !$canManage ? 'disabled' : '' ?>>
</div>
<div class="col-md-4">
<label class="form-label" for="banking_savings_monthly_interest_rate">Savings monthly interest rate (%)</label>
<input type="number" step="0.000001" min="0" class="form-control" id="banking_savings_monthly_interest_rate" name="banking_savings_monthly_interest_rate"
value="<?= htmlspecialchars((string) ($familySettings['banking_savings_monthly_interest_rate'] ?? 0), ENT_QUOTES, 'UTF-8') ?>"
<?= !$canManage ? 'disabled' : '' ?>>
</div>
<div class="col-md-6">
<label class="form-label" for="nfc_base_url">NFC base URL override (optional)</label>
<input class="form-control" id="nfc_base_url" name="nfc_base_url" placeholder="https://your-domain/familyHub"