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="= $subscriptionName ?>">
-
+ 0) { ?>
= translate("monthly_cost", $i18n) ?>
++ = CurrencyFormatter::format($totalCostPerMonth, $currencies[$userData['main_currency']]['code']) ?> +
+= translate("budget", $i18n) ?>
++ = formatPrice($monthlyBudget, $currencies[$userData['main_currency']]['code'], $currencies) ?> +
+= translate("amount_due", $i18n) ?>
+= translate("budget_used", $i18n) ?>
- = CurrencyFormatter::format($amountDueThisMonth, $currencies[$userData['main_currency']]['code']) ?> + = number_format($monthlyBudgetUsed, 2) ?>%
= translate("budget_remaining", $i18n) ?>
++ = formatPrice($monthlyBudgetLeft, $currencies[$userData['main_currency']]['code'], $currencies) ?> +
+= translate("budget", $i18n) ?>
+= translate("over_budget", $i18n) ?>
- = formatPrice($budget, $currencies[$userData['main_currency']]['code'], $currencies) ?> + = formatPrice($monthlyOverBudgetAmount, $currencies[$userData['main_currency']]['code'], $currencies) ?>
= translate('current_period', $i18n) ?>: = htmlspecialchars($budgetPeriodLabel, ENT_QUOTES, 'UTF-8') ?>
+ += translate("amount_needed_this_period", $i18n) ?>
++ = CurrencyFormatter::format($amountNeededThisPeriod, $currencies[$userData['main_currency']]['code']) ?> +
+= translate("budget", $i18n) ?>
++ = formatPrice($periodBudget, $currencies[$userData['main_currency']]['code'], $currencies) ?> +
+= translate("budget_used", $i18n) ?>
- = number_format($budgetUsed, 2) ?>% + = number_format($periodBudgetUsed, 2) ?>%
= translate("budget_remaining", $i18n) ?>
-- = formatPrice($budgetLeft, $currencies[$userData['main_currency']]['code'], $currencies) ?> -
-= translate("budget_remaining", $i18n) ?>
++ = formatPrice($periodBudgetLeft, $currencies[$userData['main_currency']]['code'], $currencies) ?> +
= translate("over_budget", $i18n) ?>
- = formatPrice($overBudgetAmount, $currencies[$userData['main_currency']]['code'], $currencies) ?> + = formatPrice($periodOverBudgetAmount, $currencies[$userData['main_currency']]['code'], $currencies) ?>
+ = translate('monthly_budget_info', $i18n) ?> +
+- = translate('budget_info', $i18n) ?> + = translate('period_budget_info', $i18n) ?>