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:
Louis Whittington 2026-03-31 11:03:53 -05:00
parent f14de0b7e1
commit 6d10cb4726
21 changed files with 1337 additions and 41 deletions

View File

@ -0,0 +1,88 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/family_settings.php';
require_once __DIR__ . '/../includes/banking_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
$actor = requireActivePerson($people);
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
sendJson(['success' => false, 'error' => 'Only a verified Head of household can adjust checking balances'], 403);
}
$settings = loadFamilySettings();
if (!bankingEnabled($settings)) {
sendJson(['success' => false, 'error' => 'Banking mode is not enabled'], 400);
}
$people = bankingApplySavingsInterestToPeople($people, $settings);
$body = readJsonBody();
$personId = trim((string) ($body['person_id'] ?? ''));
$type = trim((string) ($body['type'] ?? ''));
$note = trim((string) ($body['note'] ?? ''));
$category = bankingCategoryOrDefault((string) ($body['category'] ?? ''), 'manual');
$amountRaw = $body['amount'] ?? null;
if ($personId === '') {
sendJson(['success' => false, 'error' => 'person_id is required'], 400);
}
if (!in_array($type, ['credit', 'debit'], true)) {
sendJson(['success' => false, 'error' => 'type must be credit or debit'], 400);
}
if (!is_numeric($amountRaw)) {
sendJson(['success' => false, 'error' => 'amount must be numeric'], 400);
}
$amount = bankingRoundMoney((float) $amountRaw);
if ($amount <= 0) {
sendJson(['success' => false, 'error' => 'amount must be greater than zero'], 400);
}
$targetIdx = null;
foreach ($people as $i => $p) {
if (($p['id'] ?? '') === $personId) {
$targetIdx = $i;
break;
}
}
if ($targetIdx === null) {
sendJson(['success' => false, 'error' => 'Person not found'], 404);
}
$allocations = ['checking' => 0.0, 'savings' => 0.0, 'charity' => 0.0, 'roundup' => 0.0];
if ($type === 'credit') {
$result = bankingCreditCheckingByRule($people[$targetIdx], $amount, $settings);
$people[$targetIdx] = $result['person'];
$allocations = $result['allocations'];
} else {
$checking = (float) ($people[$targetIdx]['checking_balance'] ?? 0);
if ($checking < $amount) {
sendJson(['success' => false, 'error' => 'Insufficient checking balance'], 400);
}
$people[$targetIdx]['checking_balance'] = bankingRoundMoney($checking - $amount);
$people[$targetIdx]['currency_balance'] = $people[$targetIdx]['checking_balance'];
$allocations['checking'] = -$amount;
}
if (!writeJsonFile('people.json', $people)) {
sendJson(['success' => false, 'error' => 'Failed to save people'], 500);
}
$tx = [
'id' => bin2hex(random_bytes(8)),
'type' => 'manual_' . $type,
'person_id' => $personId,
'amount' => $amount,
'allocations' => $allocations,
'category' => $category,
'note' => $note,
'created_at' => gmdate('c'),
'created_by' => (string) ($actor['id'] ?? ''),
];
if (!appendBankTransaction($tx)) {
sendJson(['success' => false, 'error' => 'Saved balances but failed to write transaction log'], 500);
}
sendJson(['success' => true, 'transaction' => $tx, 'person' => $people[$targetIdx]]);

View File

@ -0,0 +1,74 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/family_settings.php';
require_once __DIR__ . '/../includes/banking_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
$actor = requireActivePerson($people);
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
sendJson(['success' => false, 'error' => 'Only a verified Head of household can log charity outflow'], 403);
}
$settings = loadFamilySettings();
if (!bankingEnabled($settings)) {
sendJson(['success' => false, 'error' => 'Banking mode is not enabled'], 400);
}
$people = bankingApplySavingsInterestToPeople($people, $settings);
$body = readJsonBody();
$personId = trim((string) ($body['person_id'] ?? ''));
$note = trim((string) ($body['note'] ?? ''));
$category = bankingCategoryOrDefault((string) ($body['category'] ?? ''), 'donation');
$amountRaw = $body['amount'] ?? null;
if ($personId === '') {
sendJson(['success' => false, 'error' => 'person_id is required'], 400);
}
if (!is_numeric($amountRaw)) {
sendJson(['success' => false, 'error' => 'amount must be numeric'], 400);
}
$amount = bankingRoundMoney((float) $amountRaw);
if ($amount <= 0) {
sendJson(['success' => false, 'error' => 'amount must be greater than zero'], 400);
}
$idx = null;
foreach ($people as $i => $p) {
if (($p['id'] ?? '') === $personId) {
$idx = $i;
break;
}
}
if ($idx === null) {
sendJson(['success' => false, 'error' => 'Person not found'], 404);
}
$pending = (float) ($people[$idx]['charity_pending_balance'] ?? 0);
if ($pending < $amount) {
sendJson(['success' => false, 'error' => 'Insufficient charity pending balance'], 400);
}
$people[$idx]['charity_pending_balance'] = bankingRoundMoney($pending - $amount);
$people[$idx]['charity_donated_total'] = bankingRoundMoney((float) ($people[$idx]['charity_donated_total'] ?? 0) + $amount);
if (!writeJsonFile('people.json', $people)) {
sendJson(['success' => false, 'error' => 'Failed to save people'], 500);
}
$tx = [
'id' => bin2hex(random_bytes(8)),
'type' => 'charity_outflow',
'person_id' => $personId,
'amount' => $amount,
'category' => $category,
'note' => $note,
'created_at' => gmdate('c'),
'created_by' => (string) ($actor['id'] ?? ''),
];
if (!appendBankTransaction($tx)) {
sendJson(['success' => false, 'error' => 'Saved outflow but failed to write transaction log'], 500);
}
sendJson(['success' => true, 'transaction' => $tx, 'person' => $people[$idx]]);

View File

@ -0,0 +1,133 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/family_settings.php';
require_once __DIR__ . '/../includes/banking_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
$actor = requireActivePerson($people);
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
sendJson(['success' => false, 'error' => 'Only a verified Head of household can reverse transactions'], 403);
}
$settings = loadFamilySettings();
if (!bankingEnabled($settings)) {
sendJson(['success' => false, 'error' => 'Banking mode is not enabled'], 400);
}
$people = bankingApplySavingsInterestToPeople($people, $settings);
$body = readJsonBody();
$transactionId = trim((string) ($body['transaction_id'] ?? ''));
$note = trim((string) ($body['note'] ?? ''));
if ($transactionId === '') {
sendJson(['success' => false, 'error' => 'transaction_id is required'], 400);
}
$rows = readJsonFile('bank_transactions.json');
if (!is_array($rows)) {
$rows = [];
}
$original = null;
foreach ($rows as $row) {
if (is_array($row) && (($row['id'] ?? '') === $transactionId)) {
$original = $row;
break;
}
}
if ($original === null) {
sendJson(['success' => false, 'error' => 'Transaction not found'], 404);
}
if (!empty($original['reversed_by_transaction_id'])) {
sendJson(['success' => false, 'error' => 'Transaction already reversed'], 400);
}
if (($original['type'] ?? '') === 'reversal') {
sendJson(['success' => false, 'error' => 'Cannot reverse a reversal transaction'], 400);
}
$personId = (string) ($original['person_id'] ?? '');
$idx = null;
foreach ($people as $i => $p) {
if (($p['id'] ?? '') === $personId) {
$idx = $i;
break;
}
}
if ($idx === null) {
sendJson(['success' => false, 'error' => 'Person for original transaction not found'], 404);
}
$type = (string) ($original['type'] ?? '');
$amount = bankingRoundMoney((float) ($original['amount'] ?? 0));
if ($amount <= 0) {
sendJson(['success' => false, 'error' => 'Original transaction amount is invalid'], 400);
}
if ($type === 'manual_credit' || $type === 'income_chore') {
$alloc = is_array($original['allocations'] ?? null) ? $original['allocations'] : [];
$ck = (float) ($alloc['checking'] ?? 0);
$sv = (float) ($alloc['savings'] ?? 0);
$ch = (float) ($alloc['charity'] ?? 0);
if ((float) $people[$idx]['checking_balance'] < $ck || (float) $people[$idx]['savings_balance'] < $sv || (float) $people[$idx]['charity_pending_balance'] < $ch) {
sendJson(['success' => false, 'error' => 'Insufficient balances to reverse this credit'], 400);
}
$people[$idx]['checking_balance'] = bankingRoundMoney((float) $people[$idx]['checking_balance'] - $ck);
$people[$idx]['savings_balance'] = bankingRoundMoney((float) $people[$idx]['savings_balance'] - $sv);
$people[$idx]['charity_pending_balance'] = bankingRoundMoney((float) $people[$idx]['charity_pending_balance'] - $ch);
} elseif ($type === 'manual_debit') {
$people[$idx]['checking_balance'] = bankingRoundMoney((float) $people[$idx]['checking_balance'] + $amount);
} elseif ($type === 'charity_outflow') {
if ((float) $people[$idx]['charity_donated_total'] < $amount) {
sendJson(['success' => false, 'error' => 'Insufficient donated total to reverse outflow'], 400);
}
$people[$idx]['charity_donated_total'] = bankingRoundMoney((float) $people[$idx]['charity_donated_total'] - $amount);
$people[$idx]['charity_pending_balance'] = bankingRoundMoney((float) $people[$idx]['charity_pending_balance'] + $amount);
} elseif ($type === 'transfer') {
$from = (string) ($original['from_account'] ?? '');
$to = (string) ($original['to_account'] ?? '');
$fieldMap = ['checking' => 'checking_balance', 'savings' => 'savings_balance', 'charity' => 'charity_pending_balance'];
if (!isset($fieldMap[$from], $fieldMap[$to])) {
sendJson(['success' => false, 'error' => 'Original transfer accounts are invalid'], 400);
}
$toField = $fieldMap[$to];
$fromField = $fieldMap[$from];
if ((float) $people[$idx][$toField] < $amount) {
sendJson(['success' => false, 'error' => 'Insufficient balances to reverse this transfer'], 400);
}
$people[$idx][$toField] = bankingRoundMoney((float) $people[$idx][$toField] - $amount);
$people[$idx][$fromField] = bankingRoundMoney((float) $people[$idx][$fromField] + $amount);
} else {
sendJson(['success' => false, 'error' => 'This transaction type cannot be reversed'], 400);
}
$people[$idx]['currency_balance'] = (float) ($people[$idx]['checking_balance'] ?? 0);
if (!writeJsonFile('people.json', $people)) {
sendJson(['success' => false, 'error' => 'Failed to save people balances'], 500);
}
$reversalId = bin2hex(random_bytes(8));
for ($i = 0; $i < count($rows); $i++) {
if (($rows[$i]['id'] ?? '') === $transactionId) {
$rows[$i]['reversed_by_transaction_id'] = $reversalId;
$rows[$i]['reversed_at'] = gmdate('c');
$rows[$i]['reversed_by'] = (string) ($actor['id'] ?? '');
}
}
$reversal = [
'id' => $reversalId,
'type' => 'reversal',
'person_id' => $personId,
'amount' => $amount,
'category' => 'reversal',
'note' => $note,
'reversal_of_transaction_id' => $transactionId,
'created_at' => gmdate('c'),
'created_by' => (string) ($actor['id'] ?? ''),
];
$rows[] = $reversal;
if (!writeJsonFile('bank_transactions.json', $rows)) {
sendJson(['success' => false, 'error' => 'Failed to save transaction log'], 500);
}
sendJson(['success' => true, 'transaction' => $reversal, 'person' => $people[$idx]]);

View File

@ -0,0 +1,50 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/family_settings.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
$actor = requireActivePerson($people);
$settings = loadFamilySettings();
if (empty($settings['banking_enabled'])) {
sendJson(['success' => false, 'error' => 'Banking mode is not enabled'], 400);
}
$body = readJsonBody();
$goalRaw = $body['goal_monthly'] ?? null;
if (!is_numeric($goalRaw)) {
sendJson(['success' => false, 'error' => 'goal_monthly must be numeric'], 400);
}
$goal = round((float) $goalRaw, 2);
if ($goal < 0) {
sendJson(['success' => false, 'error' => 'goal_monthly must be >= 0'], 400);
}
$targetPersonId = trim((string) ($body['person_id'] ?? ''));
$actorId = (string) ($actor['id'] ?? '');
$isHoh = (($actor['role'] ?? '') === ROLE_HEAD) && isHohVerified();
if ($targetPersonId === '') {
$targetPersonId = $actorId;
}
if (!$isHoh && $targetPersonId !== $actorId) {
sendJson(['success' => false, 'error' => 'Only verified Head of household can set goals for others'], 403);
}
$idx = null;
foreach ($people as $i => $p) {
if (($p['id'] ?? '') === $targetPersonId) {
$idx = $i;
break;
}
}
if ($idx === null) {
sendJson(['success' => false, 'error' => 'Person not found'], 404);
}
$people[$idx]['donation_goal_monthly'] = $goal;
if (!writeJsonFile('people.json', $people)) {
sendJson(['success' => false, 'error' => 'Failed to save goal'], 500);
}
sendJson(['success' => true, 'person' => $people[$idx]]);

107
api/bank_statement.php Normal file
View File

@ -0,0 +1,107 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/family_settings.php';
require_once __DIR__ . '/../includes/banking_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
$actor = requireActivePerson($people);
$settings = loadFamilySettings();
if (!bankingEnabled($settings)) {
sendJson(['success' => false, 'error' => 'Banking mode is not enabled'], 400);
}
$month = trim((string) ($_GET['month'] ?? gmdate('Y-m')));
if (!preg_match('/^\d{4}-\d{2}$/', $month)) {
sendJson(['success' => false, 'error' => 'month must be YYYY-MM'], 400);
}
$format = trim((string) ($_GET['format'] ?? 'json'));
if (!in_array($format, ['json', 'csv'], true)) {
sendJson(['success' => false, 'error' => 'format must be json or csv'], 400);
}
$personId = trim((string) ($_GET['person_id'] ?? ''));
if ($personId === '') {
$personId = (string) ($actor['id'] ?? '');
}
$isHoh = (($actor['role'] ?? '') === ROLE_HEAD) && isHohVerified();
if (!$isHoh && $personId !== (string) ($actor['id'] ?? '')) {
sendJson(['success' => false, 'error' => 'Only verified Head of household can view statements for others'], 403);
}
$person = null;
foreach ($people as $p) {
if (($p['id'] ?? '') === $personId) {
$person = $p;
break;
}
}
if ($person === null) {
sendJson(['success' => false, 'error' => 'Person not found'], 404);
}
$rows = readJsonFile('bank_transactions.json');
if (!is_array($rows)) {
$rows = [];
}
$prefix = $month . '-';
$filtered = [];
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
if ((string) ($row['person_id'] ?? '') !== $personId) {
continue;
}
$createdAt = (string) ($row['created_at'] ?? '');
$ymd = bankingYmd($createdAt);
if (strpos($ymd, $prefix) !== 0) {
continue;
}
$filtered[] = $row;
}
if ($format === 'csv') {
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="bank_statement_' . $personId . '_' . $month . '.csv"');
$out = fopen('php://output', 'w');
if ($out === false) {
exit;
}
fputcsv($out, ['id', 'created_at', 'type', 'amount', 'category', 'note', 'from_account', 'to_account', 'reversal_of_transaction_id']);
foreach ($filtered as $row) {
fputcsv($out, [
(string) ($row['id'] ?? ''),
(string) ($row['created_at'] ?? ''),
(string) ($row['type'] ?? ''),
(string) ($row['amount'] ?? ''),
(string) ($row['category'] ?? ''),
(string) ($row['note'] ?? ''),
(string) ($row['from_account'] ?? ''),
(string) ($row['to_account'] ?? ''),
(string) ($row['reversal_of_transaction_id'] ?? ''),
]);
}
fclose($out);
exit;
}
sendJson([
'success' => true,
'month' => $month,
'person' => [
'id' => (string) ($person['id'] ?? ''),
'name' => (string) ($person['name'] ?? ''),
],
'balances' => [
'checking' => (float) ($person['checking_balance'] ?? 0),
'savings' => (float) ($person['savings_balance'] ?? 0),
'charity_pending' => (float) ($person['charity_pending_balance'] ?? 0),
'charity_donated_total' => (float) ($person['charity_donated_total'] ?? 0),
],
'transactions' => $filtered,
]);

87
api/bank_transfer.php Normal file
View File

@ -0,0 +1,87 @@
<?php
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/family_settings.php';
require_once __DIR__ . '/../includes/banking_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
$actor = requireActivePerson($people);
$settings = loadFamilySettings();
if (!bankingEnabled($settings)) {
sendJson(['success' => false, 'error' => 'Banking mode is not enabled'], 400);
}
$people = bankingApplySavingsInterestToPeople($people, $settings);
$body = readJsonBody();
$from = trim((string) ($body['from_account'] ?? ''));
$to = trim((string) ($body['to_account'] ?? ''));
$note = trim((string) ($body['note'] ?? ''));
$category = bankingCategoryOrDefault((string) ($body['category'] ?? ''), 'transfer');
$amountRaw = $body['amount'] ?? null;
if (!is_numeric($amountRaw)) {
sendJson(['success' => false, 'error' => 'amount must be numeric'], 400);
}
$amount = bankingRoundMoney((float) $amountRaw);
if ($amount <= 0) {
sendJson(['success' => false, 'error' => 'amount must be greater than zero'], 400);
}
$allowedAccounts = ['checking', 'savings', 'charity'];
if (!in_array($from, $allowedAccounts, true) || !in_array($to, $allowedAccounts, true) || $from === $to) {
sendJson(['success' => false, 'error' => 'Invalid transfer accounts'], 400);
}
if ($from === 'charity') {
sendJson(['success' => false, 'error' => 'Funds in charity cannot be transferred out'], 400);
}
$actorId = (string) ($actor['id'] ?? '');
$idx = null;
foreach ($people as $i => $p) {
if (($p['id'] ?? '') === $actorId) {
$idx = $i;
break;
}
}
if ($idx === null) {
sendJson(['success' => false, 'error' => 'Active person not found'], 404);
}
$fieldMap = [
'checking' => 'checking_balance',
'savings' => 'savings_balance',
'charity' => 'charity_pending_balance',
];
$fromField = $fieldMap[$from];
$toField = $fieldMap[$to];
$fromBalance = (float) ($people[$idx][$fromField] ?? 0);
if ($fromBalance < $amount) {
sendJson(['success' => false, 'error' => 'Insufficient source balance'], 400);
}
$people[$idx][$fromField] = bankingRoundMoney($fromBalance - $amount);
$people[$idx][$toField] = bankingRoundMoney((float) ($people[$idx][$toField] ?? 0) + $amount);
$people[$idx]['currency_balance'] = (float) ($people[$idx]['checking_balance'] ?? 0);
if (!writeJsonFile('people.json', $people)) {
sendJson(['success' => false, 'error' => 'Failed to save people'], 500);
}
$tx = [
'id' => bin2hex(random_bytes(8)),
'type' => 'transfer',
'person_id' => $actorId,
'amount' => $amount,
'from_account' => $from,
'to_account' => $to,
'category' => $category,
'note' => $note,
'created_at' => gmdate('c'),
'created_by' => $actorId,
];
if (!appendBankTransaction($tx)) {
sendJson(['success' => false, 'error' => 'Saved transfer but failed to write transaction log'], 500);
}
sendJson(['success' => true, 'transaction' => $tx, 'person' => $people[$idx]]);

View File

@ -2,12 +2,16 @@
require_once __DIR__ . '/../includes/api_bootstrap.php';
require_once __DIR__ . '/../includes/chore_helpers.php';
require_once __DIR__ . '/../includes/family_settings.php';
require_once __DIR__ . '/../includes/banking_helpers.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
}
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
$familySettings = loadFamilySettings();
$people = bankingApplySavingsInterestToPeople($people, $familySettings);
$actor = requireActivePerson($people);
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
sendJson(['success' => false, 'error' => 'Only a verified Head of household can review chores'], 403);
@ -86,8 +90,25 @@ foreach ($people as $pi => $p) {
$n = count($assignees);
$creditedEach = $n > 0 ? round($value / $n, 2) : 0.0;
}
$bal = $p['currency_balance'] ?? 0;
$people[$pi]['currency_balance'] = (is_numeric($bal) ? (float) $bal : 0.0) + $creditedEach;
if (bankingEnabled($familySettings)) {
$result = bankingCreditCheckingByRule($people[$pi], $creditedEach, $familySettings);
$people[$pi] = $result['person'];
appendBankTransaction([
'id' => bin2hex(random_bytes(8)),
'type' => 'income_chore',
'person_id' => $pid,
'amount' => bankingRoundMoney($creditedEach),
'allocations' => $result['allocations'],
'category' => 'chore',
'note' => (string) ($row['title'] ?? ''),
'created_at' => gmdate('c'),
'created_by' => (string) ($actor['id'] ?? ''),
]);
} else {
$bal = $p['currency_balance'] ?? 0;
$people[$pi]['currency_balance'] = (is_numeric($bal) ? (float) $bal : 0.0) + $creditedEach;
$people[$pi]['checking_balance'] = $people[$pi]['currency_balance'];
}
$creditedRecipients++;
}

View File

@ -19,6 +19,7 @@ $allowed = [
'meal_plans' => 'meal_plans.json',
'family_settings' => 'family_settings.json',
'expenses' => 'expenses.json',
'bank_transactions' => 'bank_transactions.json',
];
$type = isset($_GET['type']) ? trim((string) $_GET['type']) : '';

View File

@ -30,6 +30,49 @@ if (isset($body['currency_permanence'])) {
}
$merged['currency_permanence'] = $p;
}
if (array_key_exists('banking_enabled', $body)) {
$merged['banking_enabled'] = !empty($body['banking_enabled']);
}
if (array_key_exists('banking_auto_split_enabled', $body)) {
$merged['banking_auto_split_enabled'] = !empty($body['banking_auto_split_enabled']);
}
if (array_key_exists('banking_auto_split_savings_pct', $body)) {
if (!is_numeric($body['banking_auto_split_savings_pct'])) {
sendJson(['success' => false, 'error' => 'banking_auto_split_savings_pct must be numeric'], 400);
}
$pct = round((float) $body['banking_auto_split_savings_pct'], 2);
if ($pct < 0 || $pct > 100) {
sendJson(['success' => false, 'error' => 'banking_auto_split_savings_pct must be 0-100'], 400);
}
$merged['banking_auto_split_savings_pct'] = $pct;
}
if (array_key_exists('banking_auto_split_charity_pct', $body)) {
if (!is_numeric($body['banking_auto_split_charity_pct'])) {
sendJson(['success' => false, 'error' => 'banking_auto_split_charity_pct must be numeric'], 400);
}
$pct = round((float) $body['banking_auto_split_charity_pct'], 2);
if ($pct < 0 || $pct > 100) {
sendJson(['success' => false, 'error' => 'banking_auto_split_charity_pct must be 0-100'], 400);
}
$merged['banking_auto_split_charity_pct'] = $pct;
}
if (array_key_exists('banking_savings_monthly_interest_rate', $body)) {
if (!is_numeric($body['banking_savings_monthly_interest_rate'])) {
sendJson(['success' => false, 'error' => 'banking_savings_monthly_interest_rate must be numeric'], 400);
}
$rate = round((float) $body['banking_savings_monthly_interest_rate'], 6);
if ($rate < 0) {
sendJson(['success' => false, 'error' => 'banking_savings_monthly_interest_rate must be >= 0'], 400);
}
$merged['banking_savings_monthly_interest_rate'] = $rate;
}
if (array_key_exists('banking_roundup_destination', $body)) {
$dest = trim((string) $body['banking_roundup_destination']);
if (!in_array($dest, ['off', 'savings', 'charity'], true)) {
sendJson(['success' => false, 'error' => 'banking_roundup_destination must be off, savings, or charity'], 400);
}
$merged['banking_roundup_destination'] = $dest;
}
if (isset($body['timezone'])) {
$tz = trim((string) $body['timezone']);
if (!in_array($tz, familyHubUsTimezoneIdentifiers(), true)) {
@ -67,6 +110,11 @@ if (array_key_exists('nfc_scan_cooldown_seconds', $body)) {
}
$merged['nfc_scan_cooldown_seconds'] = $cooldown;
}
if (
((float) ($merged['banking_auto_split_savings_pct'] ?? 0) + (float) ($merged['banking_auto_split_charity_pct'] ?? 0)) > 100
) {
sendJson(['success' => false, 'error' => 'banking auto split percentages cannot exceed 100 total'], 400);
}
$merged = normalizeLoadedFamilySettings($merged);

View File

@ -58,6 +58,12 @@ $newPerson = [
'birthday' => $birthday,
'favoriteColor' => $favoriteColor,
'currency_balance' => 0,
'checking_balance' => 0,
'savings_balance' => 0,
'charity_pending_balance' => 0,
'charity_donated_total' => 0,
'donation_goal_monthly' => 0,
'banking_interest_last_applied_at' => '',
'nfc_submit_token_hash' => '',
'nfc_submit_token_updated_at' => '',
'created_at' => gmdate('c'),

View File

@ -46,6 +46,12 @@ $newPerson = [
'birthday' => $birthday,
'favoriteColor' => $favoriteColor,
'currency_balance' => 0,
'checking_balance' => 0,
'savings_balance' => 0,
'charity_pending_balance' => 0,
'charity_donated_total' => 0,
'donation_goal_monthly' => 0,
'banking_interest_last_applied_at' => '',
'nfc_submit_token_hash' => '',
'nfc_submit_token_updated_at' => '',
'created_at' => gmdate('c'),

View File

@ -188,6 +188,12 @@
currency_symbol: $('#currency_symbol').val(),
currency_name: $('#currency_name').val(),
currency_permanence: $('#currency_permanence').val(),
banking_enabled: $('#banking_enabled').is(':checked'),
banking_auto_split_enabled: $('#banking_auto_split_enabled').is(':checked'),
banking_auto_split_savings_pct: Number($('#banking_auto_split_savings_pct').val()) || 0,
banking_auto_split_charity_pct: Number($('#banking_auto_split_charity_pct').val()) || 0,
banking_savings_monthly_interest_rate: Number($('#banking_savings_monthly_interest_rate').val()) || 0,
banking_roundup_destination: $('#banking_roundup_destination').val() || 'off',
week_starts_on: parseInt($('#week_starts_on').val(), 10),
nfc_base_url: ($('#nfc_base_url').val() || '').trim(),
nfc_show_confirmation: $('#nfc_show_confirmation').is(':checked'),
@ -1014,32 +1020,143 @@
}
function bindCurrencyPage() {
var $form = $('#expenseForm');
if (!$form.length) {
var hasLegacyExpense = $('#expenseForm').length > 0;
var hasBanking = $('#bankTransferForm').length > 0
|| $('#bankAdjustForm').length > 0
|| $('#charityOutflowForm').length > 0;
if (!hasLegacyExpense && !hasBanking) {
return;
}
$form.on('submit', function (e) {
if (hasLegacyExpense) {
$('#expenseForm').on('submit', function (e) {
e.preventDefault();
var payload = {
title: $('#expense_title').val(),
description: $('#expense_description').val(),
date: $('#expense_date').val(),
value: Number($('#expense_value').val()),
assignee_id: $('#expense_assignee').val()
};
postJson('/expense_create.php', payload)
.then(function () {
var $fb = $('#expenseFormFeedback');
showAlert($fb, 'success', 'Expense recorded. Reloading…');
$fb.removeClass('d-none');
window.location.reload();
})
.catch(function (err) {
var $fb = $('#expenseFormFeedback');
showAlert($fb, 'danger', err.message || 'Could not record expense');
$fb.removeClass('d-none');
});
});
}
$('#bankTransferForm').on('submit', function (e) {
e.preventDefault();
var payload = {
title: $('#expense_title').val(),
description: $('#expense_description').val(),
date: $('#expense_date').val(),
value: Number($('#expense_value').val()),
assignee_id: $('#expense_assignee').val()
};
postJson('/expense_create.php', payload)
var from = $('#transfer_from_account').val();
var to = $('#transfer_to_account').val();
if (to === 'charity' && !window.confirm('Transfers into charity cannot be moved back out. Continue?')) {
return;
}
postJson('/bank_transfer.php', {
from_account: from,
to_account: to,
amount: Number($('#transfer_amount').val()) || 0,
category: $('#transfer_category').val(),
note: $('#transfer_note').val()
})
.then(function () {
var $fb = $('#expenseFormFeedback');
showAlert($fb, 'success', 'Expense recorded. Reloading…');
var $fb = $('#bankTransferFeedback');
showAlert($fb, 'success', 'Transfer saved. Reloading…');
$fb.removeClass('d-none');
window.location.reload();
})
.catch(function (err) {
var $fb = $('#expenseFormFeedback');
showAlert($fb, 'danger', err.message || 'Could not record expense');
var $fb = $('#bankTransferFeedback');
showAlert($fb, 'danger', err.message || 'Could not transfer');
$fb.removeClass('d-none');
});
});
$('#bankAdjustForm').on('submit', function (e) {
e.preventDefault();
postJson('/bank_adjust_checking.php', {
person_id: $('#bank_adjust_person').val(),
type: $('#bank_adjust_type').val(),
amount: Number($('#bank_adjust_amount').val()) || 0,
category: $('#bank_adjust_category').val(),
note: $('#bank_adjust_note').val()
})
.then(function () {
var $fb = $('#bankAdjustFeedback');
showAlert($fb, 'success', 'Checking transaction saved. Reloading…');
$fb.removeClass('d-none');
window.location.reload();
})
.catch(function (err) {
var $fb = $('#bankAdjustFeedback');
showAlert($fb, 'danger', err.message || 'Could not save transaction');
$fb.removeClass('d-none');
});
});
$('#charityOutflowForm').on('submit', function (e) {
e.preventDefault();
postJson('/bank_charity_outflow.php', {
person_id: $('#charity_outflow_person').val(),
amount: Number($('#charity_outflow_amount').val()) || 0,
category: $('#charity_outflow_category').val(),
note: $('#charity_outflow_note').val()
})
.then(function () {
var $fb = $('#charityOutflowFeedback');
showAlert($fb, 'success', 'Charity outflow logged. Reloading…');
$fb.removeClass('d-none');
window.location.reload();
})
.catch(function (err) {
var $fb = $('#charityOutflowFeedback');
showAlert($fb, 'danger', err.message || 'Could not log outflow');
$fb.removeClass('d-none');
});
});
$('#donationGoalForm').on('submit', function (e) {
e.preventDefault();
postJson('/bank_set_donation_goal.php', {
person_id: $('#donation_goal_person').val(),
goal_monthly: Number($('#donation_goal_monthly').val()) || 0
})
.then(function () {
var $fb = $('#donationGoalFeedback');
showAlert($fb, 'success', 'Donation goal saved. Reloading…');
$fb.removeClass('d-none');
window.location.reload();
})
.catch(function (err) {
var $fb = $('#donationGoalFeedback');
showAlert($fb, 'danger', err.message || 'Could not save goal');
$fb.removeClass('d-none');
});
});
$(document).on('click', '.btn-bank-reverse', function () {
var txid = $(this).data('id');
if (!txid) {
return;
}
if (!window.confirm('Create a reversal entry for this transaction? This keeps audit history intact.')) {
return;
}
postJson('/bank_reverse_transaction.php', { transaction_id: txid, note: 'Reversed by HoH' })
.then(function () {
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || 'Could not reverse transaction');
});
});
}
function bindPeopleTable() {

View File

@ -17,7 +17,7 @@ if (isset($_GET['type'])) {
$type = sanitizeInput($_GET['type']);
if (in_array($type, [
'chores', 'groceries', 'meals', 'meal_plans', 'expenses',
'stores', 'grocery_lists', 'grocery_catalog',
'stores', 'grocery_lists', 'grocery_catalog', 'bank_transactions',
], true)) {
if (exportData($type)) {
echo json_encode(['success' => true, 'message' => 'Export successful']);

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);
}

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'] ?? []);

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'] = '';
}

View File

@ -7,7 +7,7 @@ require_once 'includes/family_settings.php';
startFamilyHubSession();
$people = normalizePeopleList(readJsonFile('people.json'));
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
if (getActivePersonId() !== null && getActivePerson($people) === null) {
clearPersonaSession();
}

View File

@ -4,6 +4,8 @@ A centralized family organization system with tabs for chores, groceries, meal p
## Features
- Tabbed interface for chores, groceries, meals, combined Calendar (Family Hub agenda plus optional Google embed), currency, and family settings (profiles, stores, preferences)
- Optional banking mode in the Currency tab: per-person checking, savings, and charity accounts with transfer rules and HoH controls
- Banking safeguards and reporting: irreversible transfers into charity, immutable transaction reversals, and monthly statements (JSON/CSV)
- JSON-based data storage under `data/`
- Daily automated exports (`scripts/daily_export.php` and `export.php?type=…`)
- Mobile-friendly interface
@ -15,7 +17,7 @@ A centralized family organization system with tabs for chores, groceries, meal p
- Alternatively, copy `env.example` to `.env` and edit `.env` by hand.
- See [Environment variables](#environment-variables) below for where to get each value.
3. Ensure proper permissions on the `data` directory (and that the web server can create or write JSON files there).
4. Set up the daily cron job for exports (see `scripts/daily_export.php`). It writes one JSON file per type into `exports/` (for example `chores`, `groceries`, `meals`, **`meal_plans`**, `expenses`, `stores`, `grocery_lists`, `grocery_catalog`). From the command line, the script exits with an error if `.env` is missing; complete browser setup first or copy `env.example` manually.
4. Set up the daily cron job for exports (see `scripts/daily_export.php`). It writes one JSON file per type into `exports/` (for example `chores`, `groceries`, `meals`, **`meal_plans`**, `expenses`, `stores`, `grocery_lists`, `grocery_catalog`, `bank_transactions`). From the command line, the script exits with an error if `.env` is missing; complete browser setup first or copy `env.example` manually.
5. Access the hub at `http://your-local-ip/familyHub/` (adjust path and host to match your deployment).
### Google Cloud prerequisites (OAuth + APIs)
@ -114,6 +116,28 @@ These are **not** in `.env`; they live in `data/family_settings.json` and are ed
**API:** `GET api/calendar_events.php?from=YYYY-MM-DD&to=YYYY-MM-DD` returns `{ success, events, rangeStart, rangeEnd, today }` for the signed-in profile (same session rules as other APIs). Omit `from`/`to` to use the default two-week window from **today** in the family timezone.
## Banking mode (optional)
Enable this in **Family settings → Chore economy → Banking system**.
- **Accounts per person**
- `checking_balance`: primary spendable chore income balance.
- `savings_balance`: earns the configured monthly rate, compounded daily via lazy apply when balances are read/written.
- `charity_pending_balance`: funds committed to charity and not transferable back out.
- `charity_donated_total`: historical amount already logged as donated by HoH.
- **Income routing**
- Approved chore payouts and HoH manual credits flow through checking.
- Optional auto split sends a percent of each incoming credit to savings and charity.
- Optional round-up rule moves each income credits fractional remainder to either savings or charity.
- **Permissions**
- Any active person can move their own funds between checking/savings/charity, but cannot transfer out of charity.
- Verified HoH can add checking credits/debits, log charity outflows, and reverse transactions with immutable compensating entries.
- **Donation goals**
- Per-person monthly donation goals with progress based on current-month charity outflow totals.
- **Statements**
- `GET api/bank_statement.php?month=YYYY-MM&format=json|csv[&person_id=...]`
- Non-HoH users can fetch only their own statement; verified HoH can request any person.
## NFC chore submission (HoH)
NFC support uses per-chore tokens plus per-person submitter tokens so a scan can submit as a specific person and credit that person after HoH approval.
@ -170,7 +194,7 @@ Generated links use:
## Directory Structure
```
familyHub/
├── api/ # JSON endpoints (chores, groceries, meals, people, settings, expenses, calendar_events, NFC helpers, …)
├── api/ # JSON endpoints (chores, groceries, meals, people, settings, expenses, banking, calendar_events, NFC helpers, …)
├── assets/ # Static assets
│ ├── css/ # CSS files
│ │ └── style.css
@ -185,6 +209,7 @@ familyHub/
│ ├── people.json # Profiles, roles, PIN hashes, currency balances
│ ├── family_settings.json # Currency labels, US timezone, week start, calendar merge flag, bill reminders, etc.
│ ├── expenses.json # Head-of-household expense log
│ ├── bank_transactions.json # Banking ledger (credits, debits, transfers, outflows, reversals)
│ ├── stores.json # Grocery store definitions
│ ├── grocery_lists.json # Per-store shopping lines
│ ├── grocery_catalog.json # Deduped catalog / recurring metadata

View File

@ -4,7 +4,7 @@ require_once __DIR__ . '/../includes/utils.php';
require_once __DIR__ . '/../config/config.php';
// Export all data types
$types = ['chores', 'groceries', 'meals', 'meal_plans', 'expenses', 'stores', 'grocery_lists', 'grocery_catalog'];
$types = ['chores', 'groceries', 'meals', 'meal_plans', 'expenses', 'stores', 'grocery_lists', 'grocery_catalog', 'bank_transactions'];
$results = [];
foreach ($types as $type) {

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>

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"