diff --git a/api/subscriptions/get_period_budget.php b/api/subscriptions/get_period_budget.php new file mode 100644 index 000000000..1afa9d3fe --- /dev/null +++ b/api/subscriptions/get_period_budget.php @@ -0,0 +1,175 @@ + false, + "title" => "Invalid request method" + ]); + exit; +} + +$rawBody = file_get_contents('php://input'); +$jsonData = json_decode($rawBody, true); +$payload = is_array($jsonData) ? $jsonData : []; + +$apiKey = $_REQUEST['api_key'] + ?? $_REQUEST['apiKey'] + ?? $payload['api_key'] + ?? $payload['apiKey'] + ?? null; +if (!$apiKey) { + echo json_encode([ + "success" => false, + "title" => "Missing parameters" + ]); + exit; +} + +$referenceDateRaw = $_REQUEST['reference_date'] + ?? $payload['reference_date'] + ?? null; +if ($referenceDateRaw !== null && $referenceDateRaw !== '') { + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $referenceDateRaw)) { + echo json_encode([ + "success" => false, + "title" => "Invalid parameter", + "notes" => ["reference_date must use YYYY-MM-DD format."] + ]); + exit; + } + + $referenceDate = DateTime::createFromFormat('Y-m-d', $referenceDateRaw); + if ($referenceDate === false || $referenceDate->format('Y-m-d') !== $referenceDateRaw) { + echo json_encode([ + "success" => false, + "title" => "Invalid parameter", + "notes" => ["reference_date must be a valid calendar date."] + ]); + exit; + } +} else { + $referenceDate = new DateTime('now'); +} + +$sql = "SELECT id, main_currency, period_budget, budget_period_type, budget_period_anchor_date FROM user WHERE api_key = :apiKey"; +$stmt = $db->prepare($sql); +$stmt->bindValue(':apiKey', $apiKey, SQLITE3_TEXT); +$result = $stmt->execute(); +$user = $result ? $result->fetchArray(SQLITE3_ASSOC) : false; + +if (!$user) { + echo json_encode([ + "success" => false, + "title" => "Invalid API key" + ]); + exit; +} + +$userId = (int) $user['id']; +$periodBudget = max(0, (float) ($user['period_budget'] ?? 0)); +$periodType = sanitizeBudgetPeriodType($user['budget_period_type'] ?? 'monthly'); +$anchorDate = sanitizeBudgetAnchorDate($user['budget_period_anchor_date'] ?? getDefaultBudgetAnchorDate()); + +$activePeriod = getActiveBudgetPeriod($referenceDate, $periodType, $anchorDate); + +$subsSql = "SELECT * FROM subscriptions WHERE user_id = :userId AND inactive = 0"; +$subsStmt = $db->prepare($subsSql); +$subsStmt->bindValue(':userId', $userId, SQLITE3_INTEGER); +$subsResult = $subsStmt->execute(); +$subscriptions = []; +while ($subsResult && ($subscription = $subsResult->fetchArray(SQLITE3_ASSOC))) { + $subscriptions[] = $subscription; +} + +$amountNeededFromReference = computeAmountNeededInPeriod( + $subscriptions, + $referenceDate, + $activePeriod['end'], + $db, + $userId +); + +$amountNeededFullPeriod = computeAmountNeededInPeriod( + $subscriptions, + $activePeriod['start'], + $activePeriod['end'], + $db, + $userId +); + +$amountRemaining = max(0, $periodBudget - $amountNeededFromReference); +$amountOverBudget = max(0, $amountNeededFromReference - $periodBudget); +$isOverBudget = $periodBudget > 0 && $amountNeededFromReference > $periodBudget; + +$currencyCode = null; +$currencySymbol = null; +$currencySql = "SELECT code, symbol FROM currencies WHERE id = :currencyId AND user_id = :userId LIMIT 1"; +$currencyStmt = $db->prepare($currencySql); +$currencyStmt->bindValue(':currencyId', (int) $user['main_currency'], SQLITE3_INTEGER); +$currencyStmt->bindValue(':userId', $userId, SQLITE3_INTEGER); +$currencyResult = $currencyStmt->execute(); +$currency = $currencyResult ? $currencyResult->fetchArray(SQLITE3_ASSOC) : false; + +if ($currency) { + $currencyCode = $currency['code']; + $currencySymbol = $currency['symbol']; +} + +$notes = []; +if ($periodBudget <= 0) { + $notes[] = "Period budget is set to 0."; +} + +echo json_encode([ + "success" => true, + "title" => "period_budget", + "period_budget" => round($periodBudget, 2), + "amount_needed_this_period" => round($amountNeededFromReference, 2), + "amount_needed_full_period" => round($amountNeededFullPeriod, 2), + "amount_remaining_this_period" => round($amountRemaining, 2), + "amount_over_budget" => round($amountOverBudget, 2), + "is_over_budget" => $isOverBudget, + "budget_period_type" => $periodType, + "budget_period_anchor_date" => $anchorDate, + "period_start" => $activePeriod['start']->format('Y-m-d'), + "period_end" => $activePeriod['end']->format('Y-m-d'), + "period_label" => $activePeriod['label'], + "currency_code" => $currencyCode, + "currency_symbol" => $currencySymbol, + "reference_date" => $referenceDate->format('Y-m-d'), + "notes" => $notes +], JSON_UNESCAPED_UNICODE); + +$db->close(); + +?> diff --git a/api/users/set_budget.php b/api/users/set_budget.php new file mode 100644 index 000000000..b2ac88b28 --- /dev/null +++ b/api/users/set_budget.php @@ -0,0 +1,142 @@ + false, + 'title' => 'Invalid request method', + 'message' => 'Only POST requests are allowed.' + ]); + exit; +} + +$rawBody = file_get_contents('php://input'); +$jsonData = json_decode($rawBody, true); +$payload = is_array($jsonData) ? $jsonData : $_POST; + +$apiKey = $payload['api_key'] ?? $payload['apiKey'] ?? $_POST['api_key'] ?? $_POST['apiKey'] ?? null; + +if (!$apiKey) { + echo json_encode([ + 'success' => false, + 'title' => 'Missing API key', + 'message' => 'API key is required.' + ]); + exit; +} + +$sql = "SELECT id FROM user WHERE api_key = :apiKey"; +$stmt = $db->prepare($sql); +$stmt->bindValue(':apiKey', $apiKey, SQLITE3_TEXT); +$result = $stmt->execute(); +$user = $result ? $result->fetchArray(SQLITE3_ASSOC) : false; + +if (!$user) { + echo json_encode([ + 'success' => false, + 'title' => 'Unauthorized', + 'message' => 'Invalid API key.' + ]); + exit; +} + +$hasMonthlyBudget = array_key_exists('monthly_budget', $payload) || array_key_exists('budget', $payload); +$hasPeriodBudget = array_key_exists('period_budget', $payload); +$hasPeriodMeta = array_key_exists('budget_period_type', $payload) || array_key_exists('budget_period_anchor_date', $payload); + +if (!$hasMonthlyBudget && !$hasPeriodBudget && !$hasPeriodMeta) { + echo json_encode([ + 'success' => false, + 'title' => 'Missing parameters', + 'message' => 'Provide at least one of monthly_budget/budget/period_budget/budget_period_type/budget_period_anchor_date.' + ]); + exit; +} + +$sets = []; +$binds = []; + +if ($hasMonthlyBudget) { + $monthlyBudgetRaw = $payload['monthly_budget'] ?? $payload['budget']; + if (!is_numeric($monthlyBudgetRaw)) { + echo json_encode([ + 'success' => false, + 'title' => 'Invalid parameter', + 'message' => 'monthly_budget (or budget) must be numeric.' + ]); + exit; + } + + $monthlyBudget = max(0, (float) $monthlyBudgetRaw); + $sets[] = 'budget = :monthlyBudget'; + $binds[':monthlyBudget'] = ['value' => $monthlyBudget, 'type' => SQLITE3_FLOAT]; +} + +if ($hasPeriodBudget || $hasPeriodMeta) { + if ($hasPeriodBudget) { + $periodBudgetRaw = $payload['period_budget']; + if (!is_numeric($periodBudgetRaw)) { + echo json_encode([ + 'success' => false, + 'title' => 'Invalid parameter', + 'message' => 'period_budget must be numeric.' + ]); + exit; + } + + $periodBudget = max(0, (float) $periodBudgetRaw); + $sets[] = 'period_budget = :periodBudget'; + $binds[':periodBudget'] = ['value' => $periodBudget, 'type' => SQLITE3_FLOAT]; + } + + $periodType = sanitizeBudgetPeriodType($payload['budget_period_type'] ?? 'monthly'); + $anchorDate = sanitizeBudgetAnchorDate($payload['budget_period_anchor_date'] ?? getDefaultBudgetAnchorDate()); + + $sets[] = 'budget_period_type = :periodType'; + $binds[':periodType'] = ['value' => $periodType, 'type' => SQLITE3_TEXT]; + $sets[] = 'budget_period_anchor_date = :anchorDate'; + $binds[':anchorDate'] = ['value' => $anchorDate, 'type' => SQLITE3_TEXT]; +} + +$updateSql = "UPDATE user SET " . implode(', ', $sets) . " WHERE id = :userId"; +$updateStmt = $db->prepare($updateSql); + +foreach ($binds as $key => $bind) { + $updateStmt->bindValue($key, $bind['value'], $bind['type']); +} + +$updateStmt->bindValue(':userId', (int) $user['id'], SQLITE3_INTEGER); +$updateResult = $updateStmt->execute(); + +if ($updateResult) { + echo json_encode([ + 'success' => true, + 'title' => 'Updated', + 'message' => 'Budget settings updated successfully.' + ]); +} else { + echo json_encode([ + 'success' => false, + 'title' => 'Database error', + 'message' => 'Failed to update budget settings.' + ]); +} + +$db->close(); + +?> diff --git a/endpoints/cronjobs/sendnotifications.php b/endpoints/cronjobs/sendnotifications.php index 221e43e3d..07cc1edd7 100644 --- a/endpoints/cronjobs/sendnotifications.php +++ b/endpoints/cronjobs/sendnotifications.php @@ -9,9 +9,10 @@ require __DIR__ . '/../../libs/PHPMailer/PHPMailer.php'; require __DIR__ . '/../../libs/PHPMailer/SMTP.php'; -require __DIR__ . '/../../libs/PHPMailer/Exception.php'; - -require __DIR__ . '/../../includes/currency_formatter.php'; +require __DIR__ . '/../../libs/PHPMailer/Exception.php'; + +require __DIR__ . '/../../includes/currency_formatter.php'; +require __DIR__ . '/../../includes/budget_period_calculations.php'; require 'settimezone.php'; @@ -23,9 +24,11 @@ } // Get all user ids -$query = "SELECT id, username FROM user"; -$stmt = $db->prepare($query); -$usersToNotify = $stmt->execute(); +$query = "SELECT id, username FROM user"; +$stmt = $db->prepare($query); +$usersToNotify = $stmt->execute(); +$periodSummaryColumnCheck = $db->query("SELECT * FROM pragma_table_info('notification_settings') WHERE name='period_summary_at_period_start'"); +$hasPeriodSummaryColumn = $periodSummaryColumnCheck && $periodSummaryColumnCheck->fetchArray(SQLITE3_ASSOC); function getDaysText($days) { @@ -38,7 +41,7 @@ function getDaysText($days) } } -function formatPrice($price, $currencyCode, $currencySymbol) +function formatPrice($price, $currencyCode, $currencySymbol) { $formattedPrice = CurrencyFormatter::format($price, $currencyCode); @@ -47,8 +50,32 @@ function formatPrice($price, $currencyCode, $currencySymbol) $formattedPrice = preg_replace('/\s+/', ' ', $formattedPrice); } - return $formattedPrice; -} + return $formattedPrice; +} + +function buildNotificationMessage($name, $perUser, $periodSummaryLine, $sendSummaryWhenNoRenewals) +{ + if (empty($perUser) && !$sendSummaryWhenNoRenewals) { + return ""; + } + + if (empty($perUser) && $sendSummaryWhenNoRenewals) { + return ($name ? $name . ", " : "") . $periodSummaryLine . "\n"; + } + + if ($name) { + $message = $name . ", the following subscriptions are up for renewal:\n"; + } else { + $message = "The following subscriptions are up for renewal:\n"; + } + + foreach ($perUser as $subscription) { + $dayText = getDaysText($subscription['days']); + $message .= $subscription['name'] . " for " . $subscription['formatted_price'] . " (" . $dayText . ")\n"; + } + + return $message . "\n" . $periodSummaryLine . "\n"; +} while ($userToNotify = $usersToNotify->fetchArray(SQLITE3_ASSOC)) { $userId = $userToNotify['id']; @@ -56,7 +83,8 @@ function formatPrice($price, $currencyCode, $currencySymbol) echo "For user: " . $userToNotify['username'] . "

"; } - $days = 1; + $days = 1; + $periodSummaryAtPeriodStart = 0; $emailNotificationsEnabled = false; $gotifyNotificationsEnabled = false; $telegramNotificationsEnabled = false; @@ -69,14 +97,19 @@ function formatPrice($price, $currencyCode, $currencySymbol) $serverchanNotificationsEnabled = false; // Get notification settings (how many days before the subscription ends should the notification be sent) - $query = "SELECT days FROM notification_settings WHERE user_id = :userId"; - $stmt = $db->prepare($query); - $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); - $result = $stmt->execute(); - - if ($row = $result->fetchArray(SQLITE3_ASSOC)) { - $days = $row['days']; - } + $query = $hasPeriodSummaryColumn + ? "SELECT days, period_summary_at_period_start FROM notification_settings WHERE user_id = :userId" + : "SELECT days FROM notification_settings WHERE user_id = :userId"; + $stmt = $db->prepare($query); + $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); + $result = $stmt->execute(); + + if ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $days = $row['days']; + if ($hasPeriodSummaryColumn) { + $periodSummaryAtPeriodStart = (int) ($row['period_summary_at_period_start'] ?? 0); + } + } // Check if email notifications are enabled and get the settings $query = "SELECT * FROM email_notifications WHERE user_id = :userId"; @@ -249,11 +282,46 @@ function formatPrice($price, $currencyCode, $currencySymbol) $resultCategories = $stmt->execute(); $categories = []; - while ($rowCategory = $resultCategories->fetchArray(SQLITE3_ASSOC)) { - $categories[$rowCategory['id']] = $rowCategory; - } - - $query = "SELECT * FROM subscriptions WHERE user_id = :user_id AND notify = :notify AND inactive = :inactive ORDER BY payer_user_id ASC"; + while ($rowCategory = $resultCategories->fetchArray(SQLITE3_ASSOC)) { + $categories[$rowCategory['id']] = $rowCategory; + } + + $currentDate = new DateTime('now'); + + $query = "SELECT main_currency, period_budget, budget_period_type, budget_period_anchor_date FROM user WHERE id = :userId"; + $stmt = $db->prepare($query); + $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); + $result = $stmt->execute(); + $userBudgetConfig = $result->fetchArray(SQLITE3_ASSOC); + + $mainCurrencyId = $userBudgetConfig['main_currency']; + $budgetPeriodType = sanitizeBudgetPeriodType($userBudgetConfig['budget_period_type'] ?? 'monthly'); + $budgetPeriodAnchorDate = sanitizeBudgetAnchorDate($userBudgetConfig['budget_period_anchor_date'] ?? getDefaultBudgetAnchorDate()); + $activeBudgetPeriod = getActiveBudgetPeriod($currentDate, $budgetPeriodType, $budgetPeriodAnchorDate); + $isPeriodStart = $activeBudgetPeriod['start']->format('Y-m-d') === $currentDate->format('Y-m-d'); + + $query = "SELECT price, currency_id, next_payment, cycle, frequency, inactive, auto_renew FROM subscriptions WHERE user_id = :userId AND inactive = 0"; + $stmt = $db->prepare($query); + $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); + $result = $stmt->execute(); + $periodSubscriptions = []; + while ($row = $result->fetchArray(SQLITE3_ASSOC)) { + $periodSubscriptions[] = $row; + } + + $amountNeededThisPeriod = computeAmountNeededInPeriod($periodSubscriptions, $currentDate, $activeBudgetPeriod['end'], $db, $userId); + $mainCurrencyCode = $currencies[$mainCurrencyId]['code'] ?? 'USD'; + $mainCurrencySymbol = $currencies[$mainCurrencyId]['symbol'] ?? '$'; + $periodSummaryLine = translate('amount_for_pay_period', $i18n) . ": " . formatPrice($amountNeededThisPeriod, $mainCurrencyCode, $mainCurrencySymbol); + + if (!empty($userBudgetConfig['period_budget']) && $userBudgetConfig['period_budget'] > 0) { + $remaining = max(0, $userBudgetConfig['period_budget'] - $amountNeededThisPeriod); + $periodSummaryLine .= " | " . translate('remaining', $i18n) . ": " . formatPrice($remaining, $mainCurrencyCode, $mainCurrencySymbol); + } + + $sendPeriodStartSummaryOnly = $periodSummaryAtPeriodStart === 1 && $isPeriodStart; + + $query = "SELECT * FROM subscriptions WHERE user_id = :user_id AND notify = :notify AND inactive = :inactive ORDER BY payer_user_id ASC"; $stmt = $db->prepare($query); $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); $stmt->bindValue(':notify', 1, SQLITE3_INTEGER); @@ -262,8 +330,7 @@ function formatPrice($price, $currencyCode, $currencySymbol) $notify = []; $i = 0; - $currentDate = new DateTime('now'); - while ($rowSubscription = $resultSubscriptions->fetchArray(SQLITE3_ASSOC)) { + while ($rowSubscription = $resultSubscriptions->fetchArray(SQLITE3_ASSOC)) { if ($rowSubscription['notify_days_before'] !== -1) { $daysToCompare = $rowSubscription['notify_days_before']; } else { @@ -296,7 +363,14 @@ function formatPrice($price, $currencyCode, $currencySymbol) } } - if (!empty($notify)) { + if (empty($notify) && $sendPeriodStartSummaryOnly) { + $defaultPayerUserId = array_key_first($household); + if ($defaultPayerUserId !== null) { + $notify[$defaultPayerUserId] = []; + } + } + + if (!empty($notify)) { // Email notifications if enabled if ($emailNotificationsEnabled) { @@ -308,13 +382,11 @@ function formatPrice($price, $currencyCode, $currencySymbol) $defaultEmail = $defaultUser['email']; $defaultName = $defaultUser['username']; - foreach ($notify as $userId => $perUser) { - $message = "The following subscriptions are up for renewal:\n"; - - foreach ($perUser as $subscription) { - $dayText = getDaysText($subscription['days']); - $message .= $subscription['name'] . " for " . $subscription['formatted_price'] . " (" . $dayText . ")\n"; - } + foreach ($notify as $userId => $perUser) { + $message = buildNotificationMessage("", $perUser, $periodSummaryLine, $sendPeriodStartSummaryOnly); + if ($message === "") { + continue; + } $smtpAuth = (isset($email["smtpUsername"]) && $email["smtpUsername"] != "") || (isset($email["smtpPassword"]) && $email["smtpPassword"] != ""); @@ -390,16 +462,11 @@ function formatPrice($price, $currencyCode, $currencySymbol) $title = translate('wallos_notification', $i18n); - if ($user['name']) { - $message = $user['name'] . ", the following subscriptions are up for renewal:\n"; - } else { - $message = "The following subscriptions are up for renewal:\n"; - } - - foreach ($perUser as $subscription) { - $dayText = getDaysText($subscription['days']); - $message .= $subscription['name'] . " for " . $subscription['formatted_price'] . " (" . $dayText . ")\n"; - } + $name = $user['name'] ?? ""; + $message = buildNotificationMessage($name, $perUser, $periodSummaryLine, $sendPeriodStartSummaryOnly); + if ($message === "") { + continue; + } $postfields = [ 'content' => $message @@ -450,16 +517,11 @@ function formatPrice($price, $currencyCode, $currencySymbol) $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); - if ($user['name']) { - $message = $user['name'] . ", the following subscriptions are up for renewal:\n"; - } else { - $message = "The following subscriptions are up for renewal:\n"; - } - - foreach ($perUser as $subscription) { - $dayText = getDaysText($subscription['days']); - $message .= $subscription['name'] . " for " . $subscription['formatted_price'] . " (" . $dayText . ")\n"; - } + $name = $user['name'] ?? ""; + $message = buildNotificationMessage($name, $perUser, $periodSummaryLine, $sendPeriodStartSummaryOnly); + if ($message === "") { + continue; + } $data = array( 'message' => $message, @@ -508,16 +570,15 @@ function formatPrice($price, $currencyCode, $currencySymbol) $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); - if ($user['name']) { - $message = $user['name'] . ", the following subscriptions are up for renewal:\n"; - } else { - $message = "The following subscriptions are up for renewal:\n"; - } - - foreach ($perUser as $subscription) { - $dayText = getDaysText($subscription['days']); - $message .= $subscription['name'] . " for " . $subscription['formatted_price'] . " (" . $dayText . ")\n"; - } + if ($user['name']) { + $name = $user['name']; + } else { + $name = ""; + } + $message = buildNotificationMessage($name, $perUser, $periodSummaryLine, $sendPeriodStartSummaryOnly); + if ($message === "") { + continue; + } $data = array( 'chat_id' => $telegram['chatId'], @@ -561,17 +622,11 @@ function formatPrice($price, $currencyCode, $currencySymbol) $user = $result->fetchArray(SQLITE3_ASSOC); // Build Message Content - $messageContent = ""; - if ($user['name']) { - $messageContent = $user['name'] . ", the following subscriptions are up for renewal:\n"; - } else { - $messageContent = "The following subscriptions are up for renewal:\n"; - } - - foreach ($perUser as $subscription) { - $dayText = getDaysText($subscription['days']); - $messageContent .= $subscription['name'] . " for " . $subscription['formatted_price'] . " (" . $dayText . ")\n"; - } + $name = $user['name'] ?? ""; + $messageContent = buildNotificationMessage($name, $perUser, $periodSummaryLine, $sendPeriodStartSummaryOnly); + if ($messageContent === "") { + continue; + } // Prepare PushPlus Data $data = array( @@ -626,22 +681,15 @@ function formatPrice($price, $currencyCode, $currencySymbol) $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); - // Build Message Content - $messageContent = ""; - if ($user['name']) { - $messageContent = $user['name'] . ", the following subscriptions are up for renewal:\n"; - } else { - $messageContent = "The following subscriptions are up for renewal:\n"; - } - - foreach ($perUser as $subscription) { - $dayText = getDaysText($subscription['days']); - $messageContent .= $subscription['name'] . " for " . $subscription['formatted_price'] . " (" . $dayText . ")\n"; - } - - // Prepare Mattermost Data - $webhook_url = $mattermost['webhook_url']; - $data = array( + // Build Message Content + $name = $user['name'] ?? ""; + $messageContent = buildNotificationMessage($name, $perUser, $periodSummaryLine, $sendPeriodStartSummaryOnly); + if ($messageContent === "") { + continue; + } + // Prepare Mattermost Data + $webhook_url = $mattermost['webhook_url']; + $data = array( 'username' => $mattermost['bot_username'], 'icon_emoji' => $mattermost['bot_icon_emoji'], 'text' => mb_convert_encoding($messageContent, 'UTF-8', 'auto'), @@ -691,16 +739,15 @@ function formatPrice($price, $currencyCode, $currencySymbol) $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); - if ($user['name']) { - $message = $user['name'] . ", the following subscriptions are up for renewal:\n"; - } else { - $message = "The following subscriptions are up for renewal:\n"; - } - - foreach ($perUser as $subscription) { - $dayText = getDaysText($subscription['days']); - $message .= $subscription['name'] . " for " . $subscription['formatted_price'] . " (" . $dayText . ")\n"; - } + if ($user['name']) { + $name = $user['name']; + } else { + $name = ""; + } + $message = buildNotificationMessage($name, $perUser, $periodSummaryLine, $sendPeriodStartSummaryOnly); + if ($message === "") { + continue; + } $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, "https://api.pushover.net/1/messages.json"); @@ -737,16 +784,11 @@ function formatPrice($price, $currencyCode, $currencySymbol) $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); - if ($user['name']) { - $message = $user['name'] . ", the following subscriptions are up for renewal:\n"; - } else { - $message = "The following subscriptions are up for renewal:\n"; - } - - foreach ($perUser as $subscription) { - $dayText = getDaysText($subscription['days']); - $message .= $subscription['name'] . " for " . $subscription['formatted_price'] . " (" . $dayText . ")\n"; - } + $name = $user['name'] ?? ""; + $message = buildNotificationMessage($name, $perUser, $periodSummaryLine, $sendPeriodStartSummaryOnly); + if ($message === "") { + continue; + } $headers = json_decode($ntfy["headers"], true); $customheaders = []; @@ -866,17 +908,12 @@ function formatPrice($price, $currencyCode, $currencySymbol) $result = $stmt->execute(); $user = $result->fetchArray(SQLITE3_ASSOC); - $title = 'Wallos Notification'; - if ($user['name']) { - $message = $user['name'] . ", the following subscriptions are up for renewal:\n"; - } else { - $message = "The following subscriptions are up for renewal:\n"; - } - - foreach ($perUser as $subscription) { - $dayText = getDaysText($subscription['days']); - $message .= $subscription['name'] . " for " . $subscription['formatted_price'] . " (" . $dayText . ")\n"; - } + $title = 'Wallos Notification'; + $name = $user['name'] ?? ""; + $message = buildNotificationMessage($name, $perUser, $periodSummaryLine, $sendPeriodStartSummaryOnly); + if ($message === "") { + continue; + } // Build Serverchan request $postdata = http_build_query(array('text' => $title, 'desp' => $message)); diff --git a/endpoints/notifications/savenotificationsettings.php b/endpoints/notifications/savenotificationsettings.php index b5aa025ab..7c00c397b 100644 --- a/endpoints/notifications/savenotificationsettings.php +++ b/endpoints/notifications/savenotificationsettings.php @@ -12,11 +12,19 @@ "message" => translate('fill_mandatory_fields', $i18n) ]; echo json_encode($response); -} else { - $days = $data["days"]; - $query = "SELECT COUNT(*) FROM notification_settings WHERE user_id = :userId"; - $stmt = $db->prepare($query); - $stmt->bindParam(":userId", $userId, SQLITE3_INTEGER); +} else { + $days = $data["days"]; + $periodSummaryAtPeriodStart = isset($data["period_summary_at_period_start"]) ? (int) $data["period_summary_at_period_start"] : 0; + + $hasPeriodSummaryColumn = false; + $columnResult = $db->query("SELECT * FROM pragma_table_info('notification_settings') WHERE name='period_summary_at_period_start'"); + if ($columnResult && $columnResult->fetchArray(SQLITE3_ASSOC)) { + $hasPeriodSummaryColumn = true; + } + + $query = "SELECT COUNT(*) FROM notification_settings WHERE user_id = :userId"; + $stmt = $db->prepare($query); + $stmt->bindParam(":userId", $userId, SQLITE3_INTEGER); $result = $stmt->execute(); if ($result === false) { @@ -26,18 +34,32 @@ ]; echo json_encode($response); } else { - $row = $result->fetchArray(); - $count = $row[0]; - if ($count == 0) { - $query = "INSERT INTO notification_settings (days, user_id) - VALUES (:days, :userId)"; - } else { - $query = "UPDATE notification_settings SET days = :days WHERE user_id = :userId"; - } - - $stmt = $db->prepare($query); - $stmt->bindValue(':days', $days, SQLITE3_INTEGER); - $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); + $row = $result->fetchArray(); + $count = $row[0]; + if ($count == 0) { + if ($hasPeriodSummaryColumn) { + $query = "INSERT INTO notification_settings (days, period_summary_at_period_start, user_id) + VALUES (:days, :periodSummaryAtPeriodStart, :userId)"; + } else { + $query = "INSERT INTO notification_settings (days, user_id) + VALUES (:days, :userId)"; + } + } else { + if ($hasPeriodSummaryColumn) { + $query = "UPDATE notification_settings + SET days = :days, period_summary_at_period_start = :periodSummaryAtPeriodStart + WHERE user_id = :userId"; + } else { + $query = "UPDATE notification_settings SET days = :days WHERE user_id = :userId"; + } + } + + $stmt = $db->prepare($query); + $stmt->bindValue(':days', $days, SQLITE3_INTEGER); + if ($hasPeriodSummaryColumn) { + $stmt->bindValue(':periodSummaryAtPeriodStart', $periodSummaryAtPeriodStart, SQLITE3_INTEGER); + } + $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); if ($stmt->execute()) { $response = [ @@ -53,4 +75,4 @@ echo json_encode($response); } } -} \ No newline at end of file +} diff --git a/endpoints/user/budget.php b/endpoints/user/budget.php index 0ad07f949..db716efca 100644 --- a/endpoints/user/budget.php +++ b/endpoints/user/budget.php @@ -1,33 +1,67 @@ -prepare($sql); -$stmt->bindValue(':budget', $budget, SQLITE3_TEXT); -$stmt->bindValue(':userId', $userId, SQLITE3_TEXT); -$result = $stmt->execute(); - -if ($result) { - $response = [ - "success" => true, - "message" => translate('user_details_saved', $i18n) - ]; - echo json_encode($response); -} else { - $response = [ - "success" => false, - "message" => translate('error_updating_user_data', $i18n) - ]; - echo json_encode($response); -} - - -?> \ No newline at end of file + $legacyBudget, 'type' => SQLITE3_FLOAT]; +} + +if (isset($data['monthly_budget'])) { + $monthlyBudget = max(0, (float) $data['monthly_budget']); + $sets[] = 'budget = :monthlyBudget'; + $binds[':monthlyBudget'] = ['value' => $monthlyBudget, 'type' => SQLITE3_FLOAT]; +} + +if (isset($data['period_budget'])) { + $periodBudget = max(0, (float) $data['period_budget']); + $sets[] = 'period_budget = :periodBudget'; + $binds[':periodBudget'] = ['value' => $periodBudget, 'type' => SQLITE3_FLOAT]; + + $periodType = sanitizeBudgetPeriodType($data['budget_period_type'] ?? 'monthly'); + $anchorDate = sanitizeBudgetAnchorDate($data['budget_period_anchor_date'] ?? getDefaultBudgetAnchorDate()); + + $sets[] = 'budget_period_type = :periodType'; + $binds[':periodType'] = ['value' => $periodType, 'type' => SQLITE3_TEXT]; + $sets[] = 'budget_period_anchor_date = :anchorDate'; + $binds[':anchorDate'] = ['value' => $anchorDate, 'type' => SQLITE3_TEXT]; +} + +if (empty($sets)) { + echo json_encode(["success" => false, "message" => translate('error_updating_user_data', $i18n)]); + exit; +} + +$sql = "UPDATE user SET " . implode(', ', $sets) . " WHERE id = :userId"; +$stmt = $db->prepare($sql); +foreach ($binds as $key => $bind) { + $stmt->bindValue($key, $bind['value'], $bind['type']); +} +$stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); +$result = $stmt->execute(); + +if ($result) { + $response = [ + "success" => true, + "message" => translate('user_details_saved', $i18n) + ]; +} else { + $response = [ + "success" => false, + "message" => translate('error_updating_user_data', $i18n) + ]; +} + +echo json_encode($response); + +?> diff --git a/includes/budget_period_calculations.php b/includes/budget_period_calculations.php new file mode 100644 index 000000000..7913ace2a --- /dev/null +++ b/includes/budget_period_calculations.php @@ -0,0 +1,305 @@ +format('Y-m-d'); + } +} + +if (!function_exists('sanitizeBudgetAnchorDate')) { + function sanitizeBudgetAnchorDate($anchorDate) + { + if (!is_string($anchorDate) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $anchorDate)) { + return getDefaultBudgetAnchorDate(); + } + + $parsed = DateTime::createFromFormat('Y-m-d', $anchorDate); + if ($parsed === false || $parsed->format('Y-m-d') !== $anchorDate) { + return getDefaultBudgetAnchorDate(); + } + + return $anchorDate; + } +} + +if (!function_exists('createDateAtMidnight')) { + function createDateAtMidnight(DateTime $date) + { + return new DateTime($date->format('Y-m-d')); + } +} + +if (!function_exists('getDateWithClampedDay')) { + function getDateWithClampedDay($year, $month, $day) + { + $base = DateTime::createFromFormat('!Y-n-j', $year . '-' . $month . '-1'); + if ($base === false) { + $base = new DateTime('1970-01-01'); + } + + $lastDay = (int) $base->format('t'); + $clampedDay = min(max(1, (int) $day), $lastDay); + + return DateTime::createFromFormat('!Y-n-j', $year . '-' . $month . '-' . $clampedDay); + } +} + +if (!function_exists('getActiveBudgetPeriod')) { + function getActiveBudgetPeriod(DateTime $today, $periodType, $anchorDate) + { + $periodType = sanitizeBudgetPeriodType($periodType); + $anchorDate = sanitizeBudgetAnchorDate($anchorDate ?: getDefaultBudgetAnchorDate()); + + $todayDate = createDateAtMidnight($today); + $anchor = DateTime::createFromFormat('!Y-m-d', $anchorDate); + if ($anchor === false) { + $anchor = new DateTime('1970-01-01'); + } + + if ($periodType === 'weekly' || $periodType === 'fortnightly') { + $periodLengthDays = $periodType === 'weekly' ? 7 : 14; + $diffDays = (int) $anchor->diff($todayDate)->format('%r%a'); + $periodOffset = (int) floor($diffDays / $periodLengthDays); + + $start = clone $anchor; + $start->modify(($periodOffset * $periodLengthDays) . ' day'); + + if ($start > $todayDate) { + $start->modify('-' . $periodLengthDays . ' day'); + } + + $end = clone $start; + $end->modify('+' . ($periodLengthDays - 1) . ' day'); + } else { + $anchorDay = (int) $anchor->format('j'); + $currentMonthStart = getDateWithClampedDay((int) $todayDate->format('Y'), (int) $todayDate->format('n'), $anchorDay); + + if ($todayDate < $currentMonthStart) { + $currentMonthStart->modify('first day of previous month'); + $currentMonthStart = getDateWithClampedDay((int) $currentMonthStart->format('Y'), (int) $currentMonthStart->format('n'), $anchorDay); + } + + $start = $currentMonthStart; + $nextStartMonth = clone $start; + $nextStartMonth->modify('first day of next month'); + $nextStart = getDateWithClampedDay((int) $nextStartMonth->format('Y'), (int) $nextStartMonth->format('n'), $anchorDay); + $end = clone $nextStart; + $end->modify('-1 day'); + } + + return [ + 'start' => $start, + 'end' => $end, + 'label' => formatBudgetPeriodLabel($start, $end), + 'type' => $periodType, + ]; + } +} + +if (!function_exists('formatBudgetPeriodLabel')) { + function formatBudgetPeriodLabel(DateTime $start, DateTime $end) + { + $startLabel = $start->format('M j'); + $endLabel = $end->format('M j'); + + if ($start->format('Y') !== $end->format('Y')) { + $startLabel .= ', ' . $start->format('Y'); + $endLabel .= ', ' . $end->format('Y'); + } + + return $startLabel . ' - ' . $endLabel; + } +} + +if (!function_exists('getSubscriptionIntervalSpec')) { + function getSubscriptionIntervalSpec($cycle, $frequency) + { + $frequency = max(1, (int) $frequency); + $cycle = (int) $cycle; + + $unit = match ($cycle) { + 1 => 'D', + 2 => 'W', + 3 => 'M', + 4 => 'Y', + default => null, + }; + + return $unit !== null ? 'P' . $frequency . $unit : null; + } +} + +if (!function_exists('shiftSubscriptionOccurrence')) { + function shiftSubscriptionOccurrence(DateTime $date, array $subscription, DateTime $anchorDate, $direction) + { + $frequency = max(1, (int) ($subscription['frequency'] ?? 1)); + $cycle = (int) ($subscription['cycle'] ?? 0); + $direction = $direction < 0 ? -1 : 1; + $step = $direction * $frequency; + + if ($cycle === 1) { + $shifted = clone $date; + $shifted->modify($step . ' day'); + return createDateAtMidnight($shifted); + } + + if ($cycle === 2) { + $shifted = clone $date; + $shifted->modify(($step * 7) . ' day'); + return createDateAtMidnight($shifted); + } + + if ($cycle === 3) { + $totalMonths = ((int) $date->format('Y') * 12) + ((int) $date->format('n') - 1) + $step; + $targetYear = (int) floor($totalMonths / 12); + $targetMonth = ($totalMonths % 12) + 1; + $anchorDay = (int) $anchorDate->format('j'); + + return getDateWithClampedDay($targetYear, $targetMonth, $anchorDay); + } + + if ($cycle === 4) { + $targetYear = (int) $date->format('Y') + $step; + $anchorMonth = (int) $anchorDate->format('n'); + $anchorDay = (int) $anchorDate->format('j'); + + return getDateWithClampedDay($targetYear, $anchorMonth, $anchorDay); + } + + return null; + } +} + +if (!function_exists('getSubscriptionOccurrencesInRange')) { + function getSubscriptionOccurrencesInRange(array $subscription, DateTime $rangeStart, DateTime $rangeEnd) + { + if (empty($subscription['next_payment'])) { + return []; + } + + $nextPayment = DateTime::createFromFormat('!Y-m-d', trim($subscription['next_payment'])); + if ($nextPayment === false) { + return []; + } + + $rangeStartDate = createDateAtMidnight($rangeStart); + $rangeEndDate = createDateAtMidnight($rangeEnd); + $occurrences = []; + + $autoRenew = isset($subscription['auto_renew']) ? (int) $subscription['auto_renew'] === 1 : true; + $intervalSpec = getSubscriptionIntervalSpec($subscription['cycle'] ?? 0, $subscription['frequency'] ?? 1); + + if ($intervalSpec === null) { + return []; + } + + if (!$autoRenew) { + return ($nextPayment >= $rangeStartDate && $nextPayment <= $rangeEndDate) + ? [clone $nextPayment] + : []; + } + + $current = clone $nextPayment; + $safetyCounter = 0; + + while ($current > $rangeStartDate) { + $current = shiftSubscriptionOccurrence($current, $subscription, $nextPayment, -1); + if ($current === null) { + return []; + } + $safetyCounter++; + if ($safetyCounter > 10000) { + return []; + } + } + + while ($current < $rangeStartDate) { + $current = shiftSubscriptionOccurrence($current, $subscription, $nextPayment, 1); + if ($current === null) { + return []; + } + $safetyCounter++; + if ($safetyCounter > 10000) { + return []; + } + } + + while ($current <= $rangeEndDate) { + if ($current >= $rangeStartDate) { + $occurrences[] = clone $current; + } + + $nextOccurrence = shiftSubscriptionOccurrence($current, $subscription, $nextPayment, 1); + if ($nextOccurrence === null || $nextOccurrence <= $current) { + break; + } + $current = $nextOccurrence; + $safetyCounter++; + if ($safetyCounter > 10000) { + break; + } + } + + return $occurrences; + } +} + +if (!function_exists('convertPriceToMainCurrency')) { + function convertPriceToMainCurrency($price, $currencyId, SQLite3 $database, $userId) + { + $query = "SELECT rate FROM currencies WHERE id = :currencyId AND user_id = :userId"; + $stmt = $database->prepare($query); + $stmt->bindValue(':currencyId', $currencyId, SQLITE3_INTEGER); + $stmt->bindValue(':userId', $userId, SQLITE3_INTEGER); + $result = $stmt->execute(); + $exchangeRate = $result ? $result->fetchArray(SQLITE3_ASSOC) : false; + + if ($exchangeRate === false || empty($exchangeRate['rate'])) { + return (float) $price; + } + + return (float) $price / (float) $exchangeRate['rate']; + } +} + +if (!function_exists('computeAmountNeededInPeriod')) { + function computeAmountNeededInPeriod(array $subscriptions, DateTime $today, DateTime $periodEnd, SQLite3 $database, $userId) + { + $rangeStart = createDateAtMidnight($today); + $amountNeeded = 0.0; + + foreach ($subscriptions as $subscription) { + $isActive = isset($subscription['inactive']) && (int) $subscription['inactive'] === 0; + if (!$isActive) { + continue; + } + + $occurrences = getSubscriptionOccurrencesInRange($subscription, $rangeStart, $periodEnd); + if (empty($occurrences)) { + continue; + } + + $price = convertPriceToMainCurrency( + $subscription['price'], + $subscription['currency_id'], + $database, + $userId + ); + + $amountNeeded += $price * count($occurrences); + } + + return $amountNeeded; + } +} + +?> diff --git a/includes/i18n/en.php b/includes/i18n/en.php index c4ba8274e..e2211059e 100644 --- a/includes/i18n/en.php +++ b/includes/i18n/en.php @@ -82,6 +82,7 @@ "Yearly" => "Yearly", "daily" => "Day(s)", "weekly" => "Week(s)", + "fortnightly" => "Fortnight(s)", "monthly" => "Month(s)", "yearly" => "Year(s)", "days" => "days", @@ -168,7 +169,15 @@ "api_key_info" => "The API key is used to access the API. Keep it secret.", // Settings page "monthly_budget" => "Monthly Budget", + "monthly_budget_info" => "Monthly budget compares against your total monthly subscription cost.", + "period_budget" => "Period Budget", + "period_budget_info" => "Period budget tracks what you need to pay within your chosen budget period.", "budget_info" => "Monthly budget is used to calculate statistics", + "budget_period" => "Budget period", + "budget_anchor_date" => "Anchor date", + "budget_type" => "Budget Type", + "cost_vs_monthly_budget" => "Cost vs Monthly Budget", + "cost_vs_period_budget" => "Cost vs Period Budget", "household" => "Household", "save_member" => "Save Member", "delete_member" => "Delete Member", @@ -177,8 +186,11 @@ "household_info" => "Email field allows for household members to be notified of subscriptions about to expire.", "notifications" => "Notifications", "enable_email_notifications" => "Enable email notifications", - "notify_me" => "Notify me", - "day_before" => "day before", + "notify_me" => "Notify me", + "send_period_summary_at_period_start" => "Send summary at start of each payment period", + "amount_for_pay_period" => "Amount for Pay Period", + "remaining" => "Remaining", + "day_before" => "day before", "on_due_date" => "On due date", "days_before" => "days before", "smtp_address" => "SMTP Address", @@ -425,6 +437,8 @@ "total_cost" => "Total Cost", "export_icalendar" => "Export iCalendar", "over_budget_warning" => "You're over budget", + "amount_needed_this_period" => "Amount needed this period", + "current_period" => "Current period", // TOTP Page "insert_totp_code" => "Insert TOTP code", diff --git a/includes/stats_calculations.php b/includes/stats_calculations.php index c93776a0a..bd80fe868 100644 --- a/includes/stats_calculations.php +++ b/includes/stats_calculations.php @@ -1,4 +1,5 @@ execute(); $usesMultipleCurrencies = false; +$subscriptions = []; if ($result) { while ($row = $result->fetchArray(SQLITE3_ASSOC)) { @@ -167,26 +169,6 @@ function getPriceConverted($price, $currency, $database, $userId) $mostExpensiveSubscription['name'] = $name; $mostExpensiveSubscription['logo'] = $logo; } - - // Calculate ammount due this month - $nextPaymentDate = DateTime::createFromFormat('Y-m-d', trim($next_payment)); - $tomorrow = new DateTime('tomorrow'); - $endOfMonth = new DateTime('last day of this month'); - - if ($nextPaymentDate >= $tomorrow && $nextPaymentDate <= $endOfMonth) { - $timesToPay = 1; - $daysInMonth = $endOfMonth->diff($tomorrow)->days + 1; - $daysRemaining = $endOfMonth->diff($nextPaymentDate)->days + 1; - if ($cycle == 1) { - $timesToPay = $daysRemaining / $frequency; - } - if ($cycle == 2) { - $weeksInMonth = ceil($daysInMonth / 7); - $weeksRemaining = ceil($daysRemaining / 7); - $timesToPay = $weeksRemaining / $frequency; - } - $amountDueThisMonth += $originalSubscriptionPrice * $timesToPay; - } } else { $inactiveSubscriptions++; $totalSavingsPerMonth += $price; @@ -229,30 +211,62 @@ function getPriceConverted($price, $currency, $database, $userId) } } -$showVsBudgetGraph = false; -$vsBudgetDataPoints = []; +$today = new DateTime('now'); +$budgetPeriodType = sanitizeBudgetPeriodType($userData['budget_period_type'] ?? 'monthly'); +$budgetPeriodAnchorDate = sanitizeBudgetAnchorDate($userData['budget_period_anchor_date'] ?? getDefaultBudgetAnchorDate()); +$activeBudgetPeriod = getActiveBudgetPeriod($today, $budgetPeriodType, $budgetPeriodAnchorDate); +$budgetPeriodStart = $activeBudgetPeriod['start']; +$budgetPeriodEnd = $activeBudgetPeriod['end']; +$budgetPeriodLabel = $activeBudgetPeriod['label']; + +$amountNeededThisPeriod = computeAmountNeededInPeriod($subscriptions, $today, $budgetPeriodEnd, $db, $userId); +// Keep existing variable for backwards compatibility where still referenced. +$amountDueThisMonth = $amountNeededThisPeriod; + +$showVsMonthlyBudgetGraph = false; +$vsMonthlyBudgetDataPoints = []; if (isset($userData['budget']) && $userData['budget'] > 0) { - $budget = $userData['budget']; - $budgetLeft = $budget - $totalCostPerMonth; - $budgetLeft = $budgetLeft < 0 ? 0 : $budgetLeft; - $budgetUsed = ($totalCostPerMonth / $budget) * 100; - $budgetUsed = $budgetUsed > 100 ? 100 : $budgetUsed; - if ($totalCostPerMonth > $budget) { - $overBudgetAmount = $totalCostPerMonth - $budget; + $monthlyBudget = $userData['budget']; + $monthlyBudgetLeft = max(0, $monthlyBudget - $totalCostPerMonth); + $monthlyBudgetUsed = min(100, ($totalCostPerMonth / $monthlyBudget) * 100); + if ($totalCostPerMonth > $monthlyBudget) { + $monthlyOverBudgetAmount = $totalCostPerMonth - $monthlyBudget; } - $showVsBudgetGraph = true; - $vsBudgetDataPoints = [ + $showVsMonthlyBudgetGraph = true; + $vsMonthlyBudgetDataPoints = [ [ "label" => translate('budget_remaining', $i18n), - "y" => $budgetLeft, + "y" => $monthlyBudgetLeft, ], [ - "label" => translate('total_cost', $i18n), + "label" => translate('monthly_cost', $i18n), "y" => $totalCostPerMonth, ], ]; } +$showVsPeriodBudgetGraph = false; +$vsPeriodBudgetDataPoints = []; +if (isset($userData['period_budget']) && $userData['period_budget'] > 0) { + $periodBudget = $userData['period_budget']; + $periodBudgetLeft = max(0, $periodBudget - $amountNeededThisPeriod); + $periodBudgetUsed = min(100, ($amountNeededThisPeriod / $periodBudget) * 100); + if ($amountNeededThisPeriod > $periodBudget) { + $periodOverBudgetAmount = $amountNeededThisPeriod - $periodBudget; + } + $showVsPeriodBudgetGraph = true; + $vsPeriodBudgetDataPoints = [ + [ + "label" => translate('budget_remaining', $i18n), + "y" => $periodBudgetLeft, + ], + [ + "label" => translate('amount_needed_this_period', $i18n), + "y" => $amountNeededThisPeriod, + ], + ]; +} + $showCantConverErrorMessage = false; if ($usesMultipleCurrencies) { $query = "SELECT api_key FROM fixer WHERE user_id = :userId"; @@ -279,4 +293,4 @@ function getPriceConverted($price, $currency, $database, $userId) $showTotalMonthlyCostGraph = count($totalMonthlyCostDataPoints) > 1; -?> \ No newline at end of file +?> diff --git a/index.php b/index.php index d2a809450..ee64a544f 100644 --- a/index.php +++ b/index.php @@ -259,57 +259,108 @@ class="subscription-item-logo" title=""> - + 0) { ?>
-

+

- +
+

+
+

+ +

+
+
+
+

+
+

+ +

+
+
+
-

+

- + %

- 0) { ?> +
+

+
+

+ +

+
+
+ 0) { ?>
-

+

- +

- +
+
+
+ + + 0) { ?> +
+

+ +

:

+ +
+
+
+

+
+

+ +

+
+
+
+

+
+

+ +

+
+
+

- % + %

- -
-

-
-

- -

-
+
+

+
+

+ +

- - 0) { ?> +
+ 0) { ?>

- +

@@ -401,4 +452,4 @@ class="subscription-item-logo" title=""> \ No newline at end of file +?> diff --git a/migrations/000045.php b/migrations/000045.php index dcfa05c97..dcf224a2e 100644 --- a/migrations/000045.php +++ b/migrations/000045.php @@ -1,4 +1,23 @@ +<<<<<<< HEAD +query("SELECT * FROM pragma_table_info('notification_settings') WHERE name='period_summary_at_period_start'"); +if ($columnQuery->fetchArray(SQLITE3_ASSOC) === false) { + $db->exec('ALTER TABLE notification_settings ADD COLUMN period_summary_at_period_start INTEGER DEFAULT 0'); +} + +$db->exec('UPDATE notification_settings + SET period_summary_at_period_start = 0 + WHERE period_summary_at_period_start IS NULL'); + +?> +======= exec("UPDATE user SET language = 'ja' WHERE language = 'jp'"); \ No newline at end of file +$db->exec("UPDATE user SET language = 'ja' WHERE language = 'jp'"); +>>>>>>> upstream/main diff --git a/migrations/000046.php b/migrations/000046.php new file mode 100644 index 000000000..db4b51440 --- /dev/null +++ b/migrations/000046.php @@ -0,0 +1,22 @@ +format('Y-m-d'); + +$periodTypeColumn = $db->query("SELECT * FROM pragma_table_info('user') WHERE name='budget_period_type'"); +if ($periodTypeColumn->fetchArray(SQLITE3_ASSOC) === false) { + $db->exec('ALTER TABLE user ADD COLUMN budget_period_type TEXT DEFAULT "monthly"'); +} + +$anchorDateColumn = $db->query("SELECT * FROM pragma_table_info('user') WHERE name='budget_period_anchor_date'"); +if ($anchorDateColumn->fetchArray(SQLITE3_ASSOC) === false) { + $db->exec('ALTER TABLE user ADD COLUMN budget_period_anchor_date TEXT DEFAULT "' . $defaultAnchorDate . '"'); +} + +$db->exec("UPDATE user SET budget_period_type = 'monthly' WHERE budget_period_type IS NULL OR budget_period_type = ''"); +$db->exec("UPDATE user SET budget_period_anchor_date = '" . $defaultAnchorDate . "' WHERE budget_period_anchor_date IS NULL OR budget_period_anchor_date = '' OR budget_period_anchor_date = '1970-01-01'"); + +?> diff --git a/migrations/000047.php b/migrations/000047.php new file mode 100644 index 000000000..fac8fb4db --- /dev/null +++ b/migrations/000047.php @@ -0,0 +1,15 @@ +query("SELECT * FROM pragma_table_info('user') WHERE name='period_budget'"); +if ($periodBudgetColumn->fetchArray(SQLITE3_ASSOC) === false) { + $db->exec('ALTER TABLE user ADD COLUMN period_budget REAL DEFAULT 0'); +} + +// Seed period_budget from existing budget for existing users +$db->exec("UPDATE user SET period_budget = budget WHERE (period_budget IS NULL OR period_budget = 0) AND budget > 0"); + +?> diff --git a/migrations/000048.php b/migrations/000048.php new file mode 100644 index 000000000..3b1100682 --- /dev/null +++ b/migrations/000048.php @@ -0,0 +1,70 @@ +query("PRAGMA table_info(admin)"); +$allowlistColumnExists = false; + +while ($adminQuery && ($row = $adminQuery->fetchArray(SQLITE3_ASSOC))) { + if ($row['name'] === 'local_webhook_notifications_allowlist') { + $allowlistColumnExists = true; + break; + } +} + +if (!$allowlistColumnExists) { + $db->exec("ALTER TABLE admin ADD COLUMN local_webhook_notifications_allowlist TEXT DEFAULT ''"); +} + +// Ensure uploaded_avatars table exists +$avatarsTableExists = $db->querySingle("SELECT name FROM sqlite_master WHERE type='table' AND name='uploaded_avatars'"); +if (!$avatarsTableExists) { + $db->exec(" + CREATE TABLE IF NOT EXISTS uploaded_avatars ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + path TEXT NOT NULL + ) + "); + + $userCount = (int) $db->querySingle("SELECT COUNT(*) FROM user"); + + if ($userCount === 1) { + $userId = (int) $db->querySingle("SELECT id FROM user LIMIT 1"); + $avatarDir = '../../images/uploads/logos/avatars'; + + if (is_dir($avatarDir)) { + $files = scandir($avatarDir); + $stmt = $db->prepare("INSERT INTO uploaded_avatars (user_id, path) VALUES (:user_id, :path)"); + + foreach ($files as $file) { + if ($file !== '.' && $file !== '..' && is_file($avatarDir . '/' . $file)) { + $relativePath = 'images/uploads/logos/avatars/' . $file; + $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); + $stmt->bindValue(':path', $relativePath, SQLITE3_TEXT); + $stmt->execute(); + } + } + } + } elseif ($userCount > 1) { + $users = $db->query("SELECT id, avatar FROM user"); + $stmt = $db->prepare("INSERT INTO uploaded_avatars (user_id, path) VALUES (:user_id, :path)"); + + while ($users && ($row = $users->fetchArray(SQLITE3_ASSOC))) { + $userId = (int) $row['id']; + $avatarPath = $row['avatar'] ?? ''; + + if (strpos($avatarPath, 'images/uploads/logos/avatars/') === 0) { + $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); + $stmt->bindValue(':path', $avatarPath, SQLITE3_TEXT); + $stmt->execute(); + } + } + } +} + +?> diff --git a/migrations/000049.php b/migrations/000049.php new file mode 100644 index 000000000..4f7682124 --- /dev/null +++ b/migrations/000049.php @@ -0,0 +1,4 @@ +exec("UPDATE user SET language = 'ja' WHERE language = 'jp'"); diff --git a/profile.php b/profile.php index 0e7e5bc01..dce57bdd1 100644 --- a/profile.php +++ b/profile.php @@ -1,17 +1,23 @@ prepare("SELECT path FROM uploaded_avatars WHERE user_id = :user_id"); -$stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); -$result = $stmt->execute(); - -while ($row = $result->fetchArray(SQLITE3_ASSOC)) { - $uploadedAvatars[] = $row['path']; -} -?> +// Fetch the avatars belonging to the logged-in user +$uploadedAvatars = []; + +// Keep profile page functional even if avatar migration has not run yet. +$uploadedAvatarsTableExists = $db->querySingle("SELECT name FROM sqlite_master WHERE type='table' AND name='uploaded_avatars'"); +if ($uploadedAvatarsTableExists) { + $stmt = $db->prepare("SELECT path FROM uploaded_avatars WHERE user_id = :user_id"); + if ($stmt !== false) { + $stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER); + $result = $stmt->execute(); + + while ($result && ($row = $result->fetchArray(SQLITE3_ASSOC))) { + $uploadedAvatars[] = $row['path']; + } + } +} +?> diff --git a/scripts/i18n/en.js b/scripts/i18n/en.js index e7493f7fe..a2d1aeacb 100644 --- a/scripts/i18n/en.js +++ b/scripts/i18n/en.js @@ -31,8 +31,11 @@ let i18n = { failed_remove_currency: "Failed to remove currency", failed_save_currency: "Failed to save currency", cant_disable_payment_in_use: "Can't disable payment in use", - failed_save_payment_method: "Failed to save payment method", - unknown_error: "Unknown error, please try again.", + failed_save_payment_method: "Failed to save payment method", + invalid_budget: "Budget must be a non-negative number", + invalid_budget_period: "Invalid budget period selected", + invalid_budget_anchor_date: "Anchor date must be a valid date", + unknown_error: "Unknown error, please try again.", error_saving_notification_data: "Error saving notification data", error_sending_notification: "Error sending notification", delete_account_confirmation: "Are you sure you want to delete your account?", diff --git a/scripts/notifications.js b/scripts/notifications.js index 03387fbea..4a29cd831 100644 --- a/scripts/notifications.js +++ b/scripts/notifications.js @@ -48,9 +48,13 @@ function saveNotifications() { const button = document.getElementById("saveNotifications"); button.disabled = true; const days = document.querySelector('#days').value; + const periodSummaryAtPeriodStart = document.getElementById("period_summary_at_period_start").checked ? 1 : 0; const url = 'endpoints/notifications/savenotificationsettings.php'; - const data = { days: days }; + const data = { + days: days, + period_summary_at_period_start: periodSummaryAtPeriodStart, + }; makeFetchCall(url, data, button); } @@ -434,4 +438,4 @@ function saveNotificationsServerchanButton() { }; makeFetchCall('endpoints/notifications/saveserverchannotifications.php', data, button); -} \ No newline at end of file +} diff --git a/scripts/settings.js b/scripts/settings.js index ad5307da1..c04730871 100644 --- a/scripts/settings.js +++ b/scripts/settings.js @@ -18,11 +18,78 @@ const editSvgContent = ` `; -function saveBudget() { - const button = document.getElementById("saveBudget"); +function saveMonthlyBudget() { + const button = document.getElementById("saveMonthlyBudget"); button.disabled = true; - const budget = document.getElementById("budget").value; + const budget = Number(document.getElementById("monthly_budget").value || 0); + + if (Number.isNaN(budget) || budget < 0) { + showErrorMessage(translate("invalid_budget")); + button.disabled = false; + return; + } + + fetch('endpoints/user/budget.php', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': window.csrfToken, + }, + body: JSON.stringify({ monthly_budget: budget }), + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showSuccessMessage(data.message); + } else { + showErrorMessage(data.message); + } + }) + .catch(error => { + console.error(error); + showErrorMessage(translate('unknown_error')); + }) + .finally(() => { + button.disabled = false; + }); +} + +function savePeriodBudget() { + const button = document.getElementById("savePeriodBudget"); + button.disabled = true; + + const budget = Number(document.getElementById("period_budget").value || 0); + const budgetPeriodType = document.getElementById("budget_period_type").value; + const budgetPeriodAnchorDateInput = document.getElementById("budget_period_anchor_date"); + let budgetPeriodAnchorDate = budgetPeriodAnchorDateInput.value; + const validPeriodTypes = ["weekly", "fortnightly", "monthly"]; + + if (!budgetPeriodAnchorDate || budgetPeriodAnchorDate === "1970-01-01") { + const today = new Date(); + const month = `${today.getMonth() + 1}`.padStart(2, "0"); + const day = `${today.getDate()}`.padStart(2, "0"); + budgetPeriodAnchorDate = `${today.getFullYear()}-${month}-${day}`; + budgetPeriodAnchorDateInput.value = budgetPeriodAnchorDate; + } + + if (Number.isNaN(budget) || budget < 0) { + showErrorMessage(translate("invalid_budget")); + button.disabled = false; + return; + } + + if (!validPeriodTypes.includes(budgetPeriodType)) { + showErrorMessage(translate("invalid_budget_period")); + button.disabled = false; + return; + } + + if (!/^\d{4}-\d{2}-\d{2}$/.test(budgetPeriodAnchorDate)) { + showErrorMessage(translate("invalid_budget_anchor_date")); + button.disabled = false; + return; + } fetch('endpoints/user/budget.php', { method: 'POST', @@ -30,7 +97,11 @@ function saveBudget() { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken, }, - body: JSON.stringify({budget: budget}), + body: JSON.stringify({ + period_budget: budget, + budget_period_type: budgetPeriodType, + budget_period_anchor_date: budgetPeriodAnchorDate, + }), }) .then(response => response.json()) .then(data => { @@ -1200,4 +1271,4 @@ function runAiRecommendations() { spinner.classList.add("hidden"); }); -} \ No newline at end of file +} diff --git a/scripts/stats.js b/scripts/stats.js index 50c0baa90..4eb98bf55 100644 --- a/scripts/stats.js +++ b/scripts/stats.js @@ -168,6 +168,19 @@ document.querySelectorAll('.filter-item').forEach(function(item) { urlParams.set('payment', paymentId); } + newUrl += urlParams.toString(); + window.location.href = newUrl; + } else if (this.hasAttribute('data-budgettype')) { + const budgetType = this.getAttribute('data-budgettype'); + const urlParams = new URLSearchParams(window.location.search); + let newUrl = 'stats.php?'; + + if (urlParams.get('budget') === budgetType) { + urlParams.delete('budget'); + } else { + urlParams.set('budget', budgetType); + } + newUrl += urlParams.toString(); window.location.href = newUrl; } diff --git a/settings.php b/settings.php index 9bba17af5..a44485d19 100644 --- a/settings.php +++ b/settings.php @@ -11,16 +11,72 @@ $currencies[$currencyId] = $row; } $userData['currency_symbol'] = $currencies[$main_currency]['symbol']; +$budgetPeriodType = $userData['budget_period_type'] ?? 'monthly'; +$budgetPeriodAnchorDate = $userData['budget_period_anchor_date'] ?? date('Y-m-d'); +if ($budgetPeriodAnchorDate === '1970-01-01' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $budgetPeriodAnchorDate)) { + $budgetPeriodAnchorDate = date('Y-m-d'); +} ?> - +
+ +
+
+ > + +