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:
@@ -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]]);
|
||||
@@ -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]]);
|
||||
@@ -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]]);
|
||||
@@ -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]]);
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -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]]);
|
||||
+23
-2
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user