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
+168
View File
@@ -0,0 +1,168 @@
<?php
require_once __DIR__ . '/family_settings.php';
require_once __DIR__ . '/persona.php';
function bankingEnabled(array $familySettings): bool {
return !empty($familySettings['banking_enabled']);
}
function bankingRoundMoney(float $value): float {
return round($value, 2);
}
function bankingYmd(string $isoDate): string {
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $isoDate)) {
return $isoDate;
}
if ($isoDate !== '' && strlen($isoDate) >= 10) {
$candidate = substr($isoDate, 0, 10);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $candidate)) {
return $candidate;
}
}
return gmdate('Y-m-d');
}
function bankingDaysInMonth(string $ymd): int {
$parts = explode('-', $ymd);
if (count($parts) !== 3) {
return 30;
}
$year = (int) $parts[0];
$month = (int) $parts[1];
if ($year < 1970 || $month < 1 || $month > 12) {
return 30;
}
return cal_days_in_month(CAL_GREGORIAN, $month, $year);
}
function bankingDailyRate(float $monthlyRatePercent, string $ymd): float {
if ($monthlyRatePercent <= 0) {
return 0.0;
}
$days = bankingDaysInMonth($ymd);
if ($days <= 0) {
return 0.0;
}
$monthlyRate = $monthlyRatePercent / 100.0;
return pow(1 + $monthlyRate, 1.0 / $days) - 1.0;
}
function bankingApplySavingsInterestToPerson(array $person, array $familySettings, ?string $asOfYmd = null): array {
if (!bankingEnabled($familySettings)) {
return $person;
}
$asOf = $asOfYmd !== null ? bankingYmd($asOfYmd) : gmdate('Y-m-d');
$last = bankingYmd((string) ($person['banking_interest_last_applied_at'] ?? ''));
if (($person['banking_interest_last_applied_at'] ?? '') === '') {
$person['banking_interest_last_applied_at'] = $asOf;
return $person;
}
if ($last >= $asOf) {
return $person;
}
$monthlyRate = (float) ($familySettings['banking_savings_monthly_interest_rate'] ?? 0);
if ($monthlyRate <= 0) {
$person['banking_interest_last_applied_at'] = $asOf;
return $person;
}
$savings = is_numeric($person['savings_balance'] ?? null) ? (float) $person['savings_balance'] : 0.0;
if ($savings <= 0) {
$person['banking_interest_last_applied_at'] = $asOf;
return $person;
}
$cursor = $last;
while ($cursor < $asOf) {
$next = gmdate('Y-m-d', strtotime($cursor . ' +1 day'));
$dailyRate = bankingDailyRate($monthlyRate, $next);
if ($dailyRate > 0 && $savings > 0) {
$savings = $savings * (1 + $dailyRate);
}
$cursor = $next;
}
$person['savings_balance'] = bankingRoundMoney($savings);
$person['banking_interest_last_applied_at'] = $asOf;
return $person;
}
function bankingApplySavingsInterestToPeople(array $people, array $familySettings, ?string $asOfYmd = null): array {
$out = [];
foreach ($people as $person) {
if (!is_array($person)) {
continue;
}
$out[] = bankingApplySavingsInterestToPerson($person, $familySettings, $asOfYmd);
}
return $out;
}
function bankingCreditCheckingByRule(array $person, float $amount, array $familySettings): array {
$amount = bankingRoundMoney($amount);
if ($amount <= 0) {
return ['person' => $person, 'allocations' => ['checking' => 0.0, 'savings' => 0.0, 'charity' => 0.0, 'roundup' => 0.0]];
}
$checking = $amount;
$savings = 0.0;
$charity = 0.0;
$roundup = 0.0;
if (!empty($familySettings['banking_auto_split_enabled'])) {
$sPct = (float) ($familySettings['banking_auto_split_savings_pct'] ?? 0);
$cPct = (float) ($familySettings['banking_auto_split_charity_pct'] ?? 0);
if (($sPct + $cPct) > 100) {
$cPct = max(0, 100 - $sPct);
}
$savings = bankingRoundMoney($amount * ($sPct / 100.0));
$charity = bankingRoundMoney($amount * ($cPct / 100.0));
$checking = bankingRoundMoney($amount - $savings - $charity);
}
$roundupDest = (string) ($familySettings['banking_roundup_destination'] ?? 'off');
if ($roundupDest !== 'off' && $checking > 0) {
$fraction = $checking - floor($checking);
$needed = $fraction > 0 ? bankingRoundMoney(1 - $fraction) : 0.0;
if ($needed > 0) {
$roundup = min($needed, $checking);
$checking = bankingRoundMoney($checking - $roundup);
if ($roundupDest === 'savings') {
$savings = bankingRoundMoney($savings + $roundup);
} elseif ($roundupDest === 'charity') {
$charity = bankingRoundMoney($charity + $roundup);
}
}
}
$person['checking_balance'] = bankingRoundMoney((float) ($person['checking_balance'] ?? 0) + $checking);
$person['savings_balance'] = bankingRoundMoney((float) ($person['savings_balance'] ?? 0) + $savings);
$person['charity_pending_balance'] = bankingRoundMoney((float) ($person['charity_pending_balance'] ?? 0) + $charity);
$person['currency_balance'] = $person['checking_balance'];
return [
'person' => $person,
'allocations' => [
'checking' => $checking,
'savings' => $savings,
'charity' => $charity,
'roundup' => $roundup,
],
];
}
function appendBankTransaction(array $tx): bool {
$rows = readJsonFile('bank_transactions.json');
if (!is_array($rows)) {
$rows = [];
}
$rows[] = $tx;
return writeJsonFile('bank_transactions.json', $rows);
}
function bankingCategoryOrDefault(string $category, string $default): string {
$category = trim($category);
if ($category === '') {
return $default;
}
return substr($category, 0, 50);
}
+24
View File
@@ -30,6 +30,12 @@ function familySettingsDefaultsRaw(): array {
'currency_symbol' => '★',
'currency_name' => 'Stars',
'currency_permanence' => 'permanent',
'banking_enabled' => false,
'banking_auto_split_enabled' => false,
'banking_auto_split_savings_pct' => 0,
'banking_auto_split_charity_pct' => 0,
'banking_savings_monthly_interest_rate' => 0,
'banking_roundup_destination' => 'off',
'timezone' => 'America/New_York',
'week_starts_on' => 0,
'calendar_two_way_google' => false,
@@ -80,6 +86,24 @@ function normalizeTimezoneToUs(string $tz, array $allowed): string {
function normalizeLoadedFamilySettings(array $s): array {
$allowed = familyHubUsTimezoneIdentifiers();
$s['timezone'] = normalizeTimezoneToUs((string) ($s['timezone'] ?? ''), $allowed);
$bankingEnabled = $s['banking_enabled'] ?? false;
$s['banking_enabled'] = $bankingEnabled === true || $bankingEnabled === 1 || $bankingEnabled === '1' || $bankingEnabled === 'true';
$autoSplitEnabled = $s['banking_auto_split_enabled'] ?? false;
$s['banking_auto_split_enabled'] = $autoSplitEnabled === true || $autoSplitEnabled === 1 || $autoSplitEnabled === '1' || $autoSplitEnabled === 'true';
$savingsPct = (float) ($s['banking_auto_split_savings_pct'] ?? 0);
$charityPct = (float) ($s['banking_auto_split_charity_pct'] ?? 0);
$s['banking_auto_split_savings_pct'] = max(0, min(100, round($savingsPct, 2)));
$s['banking_auto_split_charity_pct'] = max(0, min(100, round($charityPct, 2)));
if (($s['banking_auto_split_savings_pct'] + $s['banking_auto_split_charity_pct']) > 100) {
$s['banking_auto_split_charity_pct'] = max(0, round(100 - $s['banking_auto_split_savings_pct'], 2));
}
$monthlyRate = (float) ($s['banking_savings_monthly_interest_rate'] ?? 0);
$s['banking_savings_monthly_interest_rate'] = max(0, round($monthlyRate, 6));
$roundupDestination = trim((string) ($s['banking_roundup_destination'] ?? 'off'));
if (!in_array($roundupDestination, ['off', 'savings', 'charity'], true)) {
$roundupDestination = 'off';
}
$s['banking_roundup_destination'] = $roundupDestination;
$v = $s['calendar_two_way_google'] ?? false;
$s['calendar_two_way_google'] = $v === true || $v === 1 || $v === '1' || $v === 'true';
$s['calendar_bill_days'] = normalizeCalendarBillDaysRaw($s['calendar_bill_days'] ?? []);
+33
View File
@@ -103,6 +103,39 @@ function normalizePeopleList($raw): array {
* @return array<string, mixed>
*/
function migrateLegacyPersonRow(array $person): array {
$currencyBalance = is_numeric($person['currency_balance'] ?? null) ? (float) $person['currency_balance'] : 0.0;
$checkingBalance = array_key_exists('checking_balance', $person) && is_numeric($person['checking_balance'])
? (float) $person['checking_balance']
: $currencyBalance;
$person['checking_balance'] = round($checkingBalance, 2);
if (!array_key_exists('currency_balance', $person) || !is_numeric($person['currency_balance'])) {
$person['currency_balance'] = $person['checking_balance'];
} else {
$person['currency_balance'] = round((float) $person['currency_balance'], 2);
}
if (!array_key_exists('savings_balance', $person) || !is_numeric($person['savings_balance'])) {
$person['savings_balance'] = 0.0;
} else {
$person['savings_balance'] = round((float) $person['savings_balance'], 2);
}
if (!array_key_exists('charity_pending_balance', $person) || !is_numeric($person['charity_pending_balance'])) {
$person['charity_pending_balance'] = 0.0;
} else {
$person['charity_pending_balance'] = round((float) $person['charity_pending_balance'], 2);
}
if (!array_key_exists('charity_donated_total', $person) || !is_numeric($person['charity_donated_total'])) {
$person['charity_donated_total'] = 0.0;
} else {
$person['charity_donated_total'] = round((float) $person['charity_donated_total'], 2);
}
if (!array_key_exists('donation_goal_monthly', $person) || !is_numeric($person['donation_goal_monthly'])) {
$person['donation_goal_monthly'] = 0.0;
} else {
$person['donation_goal_monthly'] = max(0, round((float) $person['donation_goal_monthly'], 2));
}
if (!array_key_exists('banking_interest_last_applied_at', $person) || !is_string($person['banking_interest_last_applied_at'])) {
$person['banking_interest_last_applied_at'] = '';
}
if (!array_key_exists('nfc_submit_token_hash', $person) || !is_string($person['nfc_submit_token_hash'])) {
$person['nfc_submit_token_hash'] = '';
}