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:
parent
f14de0b7e1
commit
6d10cb4726
88
api/bank_adjust_checking.php
Normal file
88
api/bank_adjust_checking.php
Normal 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]]);
|
||||
74
api/bank_charity_outflow.php
Normal file
74
api/bank_charity_outflow.php
Normal 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]]);
|
||||
133
api/bank_reverse_transaction.php
Normal file
133
api/bank_reverse_transaction.php
Normal 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]]);
|
||||
50
api/bank_set_donation_goal.php
Normal file
50
api/bank_set_donation_goal.php
Normal 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
107
api/bank_statement.php
Normal 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
87
api/bank_transfer.php
Normal 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]]);
|
||||
@ -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++;
|
||||
}
|
||||
|
||||
|
||||
@ -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']) : '';
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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']);
|
||||
|
||||
168
includes/banking_helpers.php
Normal file
168
includes/banking_helpers.php
Normal 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);
|
||||
}
|
||||
@ -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'] ?? []);
|
||||
|
||||
@ -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'] = '';
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
29
readme.md
29
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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user