From f7a78a04480187612a07c6297fd2e708d801c30e Mon Sep 17 00:00:00 2001 From: Uniskela Date: Sat, 28 Feb 2026 02:07:19 +0000 Subject: [PATCH 1/5] Add payment-period budgeting and period summary notifications --- endpoints/cronjobs/sendnotifications.php | 282 +++++++++++------- .../savenotificationsettings.php | 58 ++-- endpoints/user/budget.php | 100 +++++-- includes/budget_period_calculations.php | 245 +++++++++++++++ includes/i18n/en.php | 18 +- includes/stats_calculations.php | 86 +++--- index.php | 95 ++++-- migrations/000043.php | 22 ++ migrations/000044.php | 15 + migrations/000045.php | 16 + scripts/i18n/en.js | 7 +- scripts/notifications.js | 8 +- scripts/settings.js | 81 ++++- scripts/stats.js | 13 + settings.php | 55 +++- stats.php | 146 ++++++--- 16 files changed, 964 insertions(+), 283 deletions(-) create mode 100644 includes/budget_period_calculations.php create mode 100644 migrations/000043.php create mode 100644 migrations/000044.php create mode 100644 migrations/000045.php diff --git a/endpoints/cronjobs/sendnotifications.php b/endpoints/cronjobs/sendnotifications.php index 0b61dc19b..8e5d38f40 100644 --- a/endpoints/cronjobs/sendnotifications.php +++ b/endpoints/cronjobs/sendnotifications.php @@ -8,9 +8,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'; @@ -22,9 +23,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) { @@ -37,7 +40,7 @@ function getDaysText($days) } } -function formatPrice($price, $currencyCode, $currencySymbol) +function formatPrice($price, $currencyCode, $currencySymbol) { $formattedPrice = CurrencyFormatter::format($price, $currencyCode); @@ -46,8 +49,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']; @@ -55,7 +82,8 @@ function formatPrice($price, $currencyCode, $currencySymbol) echo "For user: " . $userToNotify['username'] . "

"; } - $days = 1; + $days = 1; + $periodSummaryAtPeriodStart = 0; $emailNotificationsEnabled = false; $gotifyNotificationsEnabled = false; $telegramNotificationsEnabled = false; @@ -68,14 +96,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"; @@ -248,11 +281,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); @@ -261,8 +329,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 { @@ -295,7 +362,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) { @@ -307,13 +381,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"] != ""); @@ -385,16 +457,15 @@ 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"; - } + if ($user['name']) { + $name = $user['name']; + } else { + $name = ""; + } + $message = buildNotificationMessage($name, $perUser, $periodSummaryLine, $sendPeriodStartSummaryOnly); + if ($message === "") { + continue; + } $postfields = [ 'content' => $message @@ -438,16 +509,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( 'message' => $message, @@ -492,16 +562,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'], @@ -543,17 +612,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( @@ -605,17 +668,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 Mattermost Data $webhook_url = $mattermost['webhook_url']; @@ -667,16 +724,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"); @@ -709,16 +765,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; + } $headers = json_decode($ntfy["headers"], true); $customheaders = []; @@ -828,17 +883,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..bc64cd28e --- /dev/null +++ b/includes/budget_period_calculations.php @@ -0,0 +1,245 @@ +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 = new DateTime($anchorDate); + + 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) (new DateTime($anchorDate))->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('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 []; + } + + $interval = new DateInterval($intervalSpec); + $current = clone $nextPayment; + $safetyCounter = 0; + + while ($current < $rangeStartDate) { + if (!$autoRenew) { + return []; + } + $current->add($interval); + $safetyCounter++; + if ($safetyCounter > 10000) { + return []; + } + } + + while ($current <= $rangeEndDate) { + if ($current >= $rangeStartDate) { + $occurrences[] = clone $current; + } + + if (!$autoRenew) { + break; + } + + $current->add($interval); + $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 9ee31d9d3..5ae704432 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", @@ -422,6 +434,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 1eb2d8243..ee8bac24c 100644 --- a/index.php +++ b/index.php @@ -222,57 +222,108 @@ class="subscription-item-logo" title=""> - + 0) { ?>
-

+

- +
+

+
+

+ +

+
+
+
+

+
+

+ +

+
+
+
-

+

- + %

- 0) { ?> +
+

+
+

+ +

+
+
+ 0) { ?>
-

+

- +

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

+ +

:

+ +
+
+
+

+
+

+ +

+
+
+
+

+
+

+ +

+
+
+

- % + %

- -
-

-
-

- -

-
+
+

+
+

+ +

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

- +

@@ -364,4 +415,4 @@ class="subscription-item-logo" title=""> \ No newline at end of file +?> diff --git a/migrations/000043.php b/migrations/000043.php new file mode 100644 index 000000000..db4b51440 --- /dev/null +++ b/migrations/000043.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/000044.php b/migrations/000044.php new file mode 100644 index 000000000..ce41a0b40 --- /dev/null +++ b/migrations/000044.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/000045.php b/migrations/000045.php new file mode 100644 index 000000000..92795ff6c --- /dev/null +++ b/migrations/000045.php @@ -0,0 +1,16 @@ +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'); + +?> 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 9713a6244..5598db99e 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 => { @@ -1155,4 +1226,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 bad289d6b..06cbb528a 100644 --- a/settings.php +++ b/settings.php @@ -11,6 +11,11 @@ $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'); +} ?> @@ -29,14 +34,44 @@ + + +
+
+ > + +