diff --git a/api/bank_adjust_checking.php b/api/bank_adjust_checking.php new file mode 100644 index 0000000..7fc1574 --- /dev/null +++ b/api/bank_adjust_checking.php @@ -0,0 +1,88 @@ + 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]]); diff --git a/api/bank_charity_outflow.php b/api/bank_charity_outflow.php new file mode 100644 index 0000000..3b09656 --- /dev/null +++ b/api/bank_charity_outflow.php @@ -0,0 +1,74 @@ + 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]]); diff --git a/api/bank_reverse_transaction.php b/api/bank_reverse_transaction.php new file mode 100644 index 0000000..9ebe124 --- /dev/null +++ b/api/bank_reverse_transaction.php @@ -0,0 +1,133 @@ + 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]]); diff --git a/api/bank_set_donation_goal.php b/api/bank_set_donation_goal.php new file mode 100644 index 0000000..2ba13d2 --- /dev/null +++ b/api/bank_set_donation_goal.php @@ -0,0 +1,50 @@ + 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]]); diff --git a/api/bank_statement.php b/api/bank_statement.php new file mode 100644 index 0000000..a8ab7b1 --- /dev/null +++ b/api/bank_statement.php @@ -0,0 +1,107 @@ + 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, +]); diff --git a/api/bank_transfer.php b/api/bank_transfer.php new file mode 100644 index 0000000..1014b47 --- /dev/null +++ b/api/bank_transfer.php @@ -0,0 +1,87 @@ + 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]]); diff --git a/api/chore_review.php b/api/chore_review.php index bd02521..5a5b690 100644 --- a/api/chore_review.php +++ b/api/chore_review.php @@ -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++; } diff --git a/api/data_snapshot.php b/api/data_snapshot.php index 6475dbb..dd425c3 100644 --- a/api/data_snapshot.php +++ b/api/data_snapshot.php @@ -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']) : ''; diff --git a/api/family_settings_save.php b/api/family_settings_save.php index a07c9f4..2fd7105 100644 --- a/api/family_settings_save.php +++ b/api/family_settings_save.php @@ -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); diff --git a/api/people_create.php b/api/people_create.php index eb1bd86..2538f20 100644 --- a/api/people_create.php +++ b/api/people_create.php @@ -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'), diff --git a/api/people_create_first.php b/api/people_create_first.php index 38813c5..dfd9f23 100644 --- a/api/people_create_first.php +++ b/api/people_create_first.php @@ -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'), diff --git a/assets/js/main.js b/assets/js/main.js index 35d3694..1059fd7 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -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() { diff --git a/export.php b/export.php index 206a08b..86eb67a 100644 --- a/export.php +++ b/export.php @@ -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']); diff --git a/includes/banking_helpers.php b/includes/banking_helpers.php new file mode 100644 index 0000000..d33988a --- /dev/null +++ b/includes/banking_helpers.php @@ -0,0 +1,168 @@ += 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); +} diff --git a/includes/family_settings.php b/includes/family_settings.php index 3dd02a2..a83f4b1 100644 --- a/includes/family_settings.php +++ b/includes/family_settings.php @@ -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'] ?? []); diff --git a/includes/persona.php b/includes/persona.php index 1a15945..f6aa90f 100644 --- a/includes/persona.php +++ b/includes/persona.php @@ -103,6 +103,39 @@ function normalizePeopleList($raw): array { * @return array */ 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'] = ''; } diff --git a/index.php b/index.php index c41cdb0..d4a38f9 100644 --- a/index.php +++ b/index.php @@ -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(); } diff --git a/readme.md b/readme.md index a92e68f..2ad52e8 100644 --- a/readme.md +++ b/readme.md @@ -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 credit’s 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 diff --git a/scripts/daily_export.php b/scripts/daily_export.php index dc5ea1e..074d43a 100644 --- a/scripts/daily_export.php +++ b/scripts/daily_export.php @@ -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) { diff --git a/tabs/currency.php b/tabs/currency.php index 77c91df..4b9a18a 100644 --- a/tabs/currency.php +++ b/tabs/currency.php @@ -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); +} ?>
@@ -65,7 +100,15 @@ $today = gmdate('Y-m-d'); # Name - Balance + + Checking + Savings + Charity pending + Donated total + Donation goal progress + + Balance + @@ -73,12 +116,36 @@ $today = gmdate('Y-m-d'); 0 ? (int) max(0, min(100, round(($donatedThisMonth / $goal) * 100))) : 0; $isSelf = $activePerson && ($activePerson['id'] ?? '') === $pid; ?> You - + + + + + + + 0): ?> +
+
+
+
/
+ + No goal set + + + + + @@ -90,7 +157,151 @@ $today = gmdate('Y-m-d');
- + +
+
+ Move your funds +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+
+ +
+
Monthly donation goal
+
+
+ +
+ + +
+ + + +
+ + +
+
+ +
+
+
+
+
+
+
+ + +
+
HoH checking credit/debit
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+
HoH charity outflow
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ +
Record expense @@ -143,36 +354,86 @@ $today = gmdate('Y-m-d');
-
Recent expenses
+
- + +

No bank transactions recorded yet.

+

No expenses recorded yet.

- - - - + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + +
DateTitleToAmountDatePersonTypeCategoryNoteAmountDateTitleToAmount
+ + + +
+ +
+
Monthly statements
+
+

Download your monthly statement as JSON or CSV.

+ +
+
+
diff --git a/tabs/settings.php b/tabs/settings.php index 06101b2..9bbd851 100644 --- a/tabs/settings.php +++ b/tabs/settings.php @@ -164,6 +164,53 @@ $permanenceOptions = [ value="" > +

+
+

Banking system (optional)

+
+
+
+ + > + +
+
+
+
+ + > + +
+
+
+ + +
+
+ + > +
+
+ + > +
+
+ + > +