Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions budget/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Dashboard charts and reports can count budget periods.** With a **Budget Cycle Start Day** set, Settings has **Months in dashboard charts and reports**, with **Calendar months** (the default) or **Budget periods**. With budget periods and start day 26, a salary on 26 September counts towards October in the dashboard's trend and spending charts, the summary trend, cash flow by month and the category × month report, the same as on the Budget page. The report presets then cover whole budget periods, and a line under each chart names the start day. Exports and scheduled reports of those reports follow the choice; year-over-year and the digest stay on calendar months. Reported by @saesch83 ([#457](https://github.com/otherworld-dev/Budget/issues/457))

### Fixed
- **Trend charts keep their last month.** The income vs. expenses trend stepped a month at a time from the start date, so a range like 26 July to 25 October lost October, and a range starting on 31 January skipped February. It now lists every month the range touches, in calendar mode too.
- **The dashboard's "% vs last month" compares like with like.** The income, expenses and cash flow tiles read the current period's calendar-month buckets, so with a start day the comparison was missing or compared two halves of one period. They now compare this budget period so far with the same number of days of the one before, so the 1st of the month no longer reads "down 100%".
- **Bank sync shows what the bank sends as text.** The balance and currency in **Account Mappings** went into the page as they came from the bank provider, so a provider sending markup could run script in Budget. They are now shown as plain text, and the server only keeps a currency that is a three-letter code and a balance that is a number.
- **The bank authorization page opens safely.** The GoCardless authorization link now opens with no link back to the Budget window and no referrer, and the server refuses a link that isn't https before your browser sees it. The "click here to authorize" link is always shown, since the browser can no longer say whether the window opened.
- **A factory reset ends your bank connections at the provider.** Factory reset, clearing the sample data and purging a deleted user removed the bank connections from Budget but left the consent at GoCardless running, as only **Disconnect** revoked it. They now revoke each connection once the data has gone. A provider that can't be reached is logged and never stops the reset.
Expand Down
6 changes: 6 additions & 0 deletions budget/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -6879,6 +6879,12 @@ button.danger:hover {
.category-chart-header h5 {
margin: 0;
}
/* Says a chart's or report's months are budget months (#457) */
.month-grouping-note {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-text-maxcontrast);
}
/* Says the bars are budget months when a budget start day is set (#447) */
.category-chart-note {
margin: -8px 0 12px;
Expand Down
2 changes: 1 addition & 1 deletion budget/js/budget-app.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion budget/js/budget-app.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions budget/js/budget-reports.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion budget/js/budget-reports.js.map

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions budget/lib/Controller/SettingController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use OCA\Budget\Enum\Currency;
use OCA\Budget\Service\AmountColours;
use OCA\Budget\Service\AttachmentService;
use OCA\Budget\Service\BudgetCarryoverService;
use OCA\Budget\Service\GranularShareService;
use OCA\Budget\Service\ImportFolderService;
use OCA\Budget\Traits\ApiErrorHandlerTrait;
Expand Down Expand Up @@ -56,6 +57,7 @@ class SettingController extends Controller {
'export_default_format' => 'csv',
'budget_period' => 'monthly',
'budget_start_day' => '1',
BudgetCarryoverService::REPORT_MONTHS_SETTING => 'calendar', // or 'budget' (#457)
'budget_alert_threshold' => '80',
'budget_alert_scope' => 'all', // or 'manual': only budgets the user set (#389)
'budget_alert_muted_categories' => '[]',
Expand Down
31 changes: 31 additions & 0 deletions budget/lib/Db/ReportScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace OCA\Budget\Db;

use OCA\Budget\Service\BudgetPeriod;
use OCA\Budget\Service\MoneyCalculator;
use OCP\DB\QueryBuilder\ICompositeExpression;
use OCP\DB\QueryBuilder\IQueryBuilder;
Expand Down Expand Up @@ -43,6 +44,36 @@ public static function monthExpr(string $alias = 't'): string {
return "SUBSTR(CAST({$alias}.date AS CHAR(10)), 1, 7)";
}

/**
* The bucket a month-by-month report groups by. Calendar months
* ($monthStartDay 1) are cut in SQL. A budget month can't be cut out of
* a date in SQL every database runs, so with a budget start day the
* query groups by date and toBudgetMonths() names each date's month.
*/
public static function monthBucketExpr(int $monthStartDay, string $alias = 't'): string {
return $monthStartDay > 1 ? "CAST({$alias}.date AS CHAR(10))" : self::monthExpr($alias);
}

/**
* Rows grouped by monthBucketExpr(), with $column turned from a date into
* its budget month (Y-m). Several dates then share a month, so the rows
* go through mergeReportHalves() afterwards to add them up. Calendar
* month rows come back unchanged.
*
* @param array[] $rows
* @return array[]
*/
public static function toBudgetMonths(array $rows, int $monthStartDay, string $column = 'month'): array {
if ($monthStartDay <= 1) {
return $rows;
}
foreach ($rows as &$row) {
$row[$column] = BudgetPeriod::monthContaining(substr((string)$row[$column], 0, 10), $monthStartDay);
}
unset($row);
return $rows;
}

/**
* Apply user scope to a query — either by userId or by visible account IDs.
* Used for granular sharing where the user can see specific shared accounts.
Expand Down
56 changes: 42 additions & 14 deletions budget/lib/Db/TransactionReportQueries.php
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,9 @@ function (IQueryBuilder $qb, string $alloc): void {
*
* @param int[] $tagIds Optional tag filter (OR logic)
* @param bool $includeUntagged Include untagged transactions when filtering by tags
* @param int $monthStartDay 1 for calendar months; otherwise the budget
* start day, and 'month' is the budget month
* (BudgetPeriod) each transaction falls in
* @return array<int, array{month: string, income: float, expenses: float, net: float, count: int}>
*/
public function getCashFlowByMonth(
Expand All @@ -228,19 +231,27 @@ public function getCashFlowByMonth(
bool $includeUntagged = true,
bool $excludeTransfers = false,
?array $visibleAccountIds = null,
int $monthStartDay = 1,
): array {
$bucket = ReportScope::monthBucketExpr($monthStartDay);
[$direct, $split] = $this->reportHalves(
$userId, $accountId, $startDate, $endDate, $visibleAccountIds, $excludeTransfers,
function (IQueryBuilder $qb, string $alloc) use ($tagIds, $includeUntagged): void {
$qb->select($qb->createFunction(ReportScope::monthExpr() . ' as month'));
function (IQueryBuilder $qb, string $alloc) use ($tagIds, $includeUntagged, $bucket): void {
$qb->select($qb->createFunction($bucket . ' as month'));
ReportScope::selectIncomeExpenses($qb, $alloc);
$qb->selectAlias($qb->createFunction('COUNT(DISTINCT t.id)'), 'count');
ReportScope::applyTagFilter($qb, $tagIds, $includeUntagged);
$qb->groupBy($qb->createFunction(ReportScope::monthExpr()));
$qb->groupBy($qb->createFunction($bucket));
}
);

$rows = ReportScope::mergeReportHalves($direct, $split, ['month'], ['income', 'expenses'], ['count']);
// A transaction has one date, so adding up the counts of a budget
// month's dates counts each transaction once
$rows = ReportScope::mergeReportHalves(
ReportScope::toBudgetMonths($direct, $monthStartDay),
ReportScope::toBudgetMonths($split, $monthStartDay),
['month'], ['income', 'expenses'], ['count']
);
usort($rows, static fn (array $a, array $b) => strcmp((string)$a['month'], (string)$b['month']));

return array_map(fn (array $row) => [
Expand Down Expand Up @@ -269,20 +280,26 @@ public function getCashFlowByMonthByAccount(
bool $includeUntagged = true,
bool $excludeTransfers = false,
?array $visibleAccountIds = null,
int $monthStartDay = 1,
): array {
$bucket = ReportScope::monthBucketExpr($monthStartDay);
[$direct, $split] = $this->reportHalves(
$userId, null, $startDate, $endDate, $visibleAccountIds, $excludeTransfers,
function (IQueryBuilder $qb, string $alloc) use ($tagIds, $includeUntagged): void {
function (IQueryBuilder $qb, string $alloc) use ($tagIds, $includeUntagged, $bucket): void {
$qb->select('t.account_id')
->addSelect($qb->createFunction(ReportScope::monthExpr() . ' as month'));
->addSelect($qb->createFunction($bucket . ' as month'));
ReportScope::selectIncomeExpenses($qb, $alloc);
$qb->selectAlias($qb->createFunction('COUNT(DISTINCT t.id)'), 'count');
ReportScope::applyTagFilter($qb, $tagIds, $includeUntagged);
$qb->groupBy('t.account_id', $qb->createFunction(ReportScope::monthExpr()));
$qb->groupBy('t.account_id', $qb->createFunction($bucket));
}
);

$rows = ReportScope::mergeReportHalves($direct, $split, ['account_id', 'month'], ['income', 'expenses'], ['count']);
$rows = ReportScope::mergeReportHalves(
ReportScope::toBudgetMonths($direct, $monthStartDay),
ReportScope::toBudgetMonths($split, $monthStartDay),
['account_id', 'month'], ['income', 'expenses'], ['count']
);
usort($rows, static fn (array $a, array $b) => strcmp((string)$a['month'], (string)$b['month']));

return array_map(static fn (array $row) => [
Expand Down Expand Up @@ -315,13 +332,14 @@ public function getMonthlyTrendData(
bool $includeUntagged = true,
bool $excludeTransfers = false,
?array $visibleAccountIds = null,
int $monthStartDay = 1,
): array {
return array_map(static fn (array $row) => [
'month' => $row['month'],
'income' => $row['income'],
'expenses' => $row['expenses'],
], $this->getCashFlowByMonth(
$userId, $accountId, $startDate, $endDate, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds
$userId, $accountId, $startDate, $endDate, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds, $monthStartDay
));
}

Expand All @@ -342,14 +360,15 @@ public function getMonthlyTrendDataByAccount(
bool $includeUntagged = true,
bool $excludeTransfers = false,
?array $visibleAccountIds = null,
int $monthStartDay = 1,
): array {
return array_map(static fn (array $row) => [
'month' => $row['month'],
'account_id' => $row['account_id'],
'income' => $row['income'],
'expenses' => $row['expenses'],
], $this->getCashFlowByMonthByAccount(
$userId, $startDate, $endDate, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds
$userId, $startDate, $endDate, $tagIds, $includeUntagged, $excludeTransfers, $visibleAccountIds, $monthStartDay
));
}

Expand Down Expand Up @@ -786,6 +805,8 @@ function (IQueryBuilder $qb, string $alloc) use ($tagIds): void {
* under key UNCATEGORIZED, so the report's totals match the month view.
*
* @param int[]|null $visibleAccountIds
* @param int $monthStartDay 1 for calendar months, else budget months
* starting on that day (see getCashFlowByMonth())
* @return array<int, array<string, float>> categoryId => 'YYYY-MM' => net
*/
public function getCategoryNetByMonth(
Expand All @@ -794,20 +815,27 @@ public function getCategoryNetByMonth(
string $endDate,
?int $accountId = null,
?array $visibleAccountIds = null,
int $monthStartDay = 1,
): array {
$bucket = ReportScope::monthBucketExpr($monthStartDay);
[$direct, $split] = $this->reportHalves(
$userId, $accountId, $startDate, $endDate, $visibleAccountIds, $accountId === null,
function (IQueryBuilder $qb, string $alloc): void {
function (IQueryBuilder $qb, string $alloc) use ($bucket): void {
$qb->select("{$alloc}.category_id")
->addSelect($qb->createFunction(ReportScope::monthExpr() . ' as month'))
->addSelect($qb->createFunction($bucket . ' as month'))
->selectAlias($qb->createFunction(ReportScope::signedAmountSum($qb, 'credit', "{$alloc}.amount")), 'net')
->groupBy("{$alloc}.category_id")
->addGroupBy($qb->createFunction(ReportScope::monthExpr()));
->addGroupBy($qb->createFunction($bucket));
}
);

$totals = [];
foreach (ReportScope::mergeReportHalves($direct, $split, ['category_id', 'month'], ['net']) as $row) {
$merged = ReportScope::mergeReportHalves(
ReportScope::toBudgetMonths($direct, $monthStartDay),
ReportScope::toBudgetMonths($split, $monthStartDay),
['category_id', 'month'], ['net']
);
foreach ($merged as $row) {
$catId = $row['category_id'] === null ? self::UNCATEGORIZED : (int)$row['category_id'];
$totals[$catId][substr((string)$row['month'], 0, 7)] = $row['net'];
}
Expand Down
14 changes: 14 additions & 0 deletions budget/lib/Service/BudgetCarryoverService.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@
*/
class BudgetCarryoverService {

/** 'calendar' (the default) or 'budget': how the charts and reports cut months */
public const REPORT_MONTHS_SETTING = 'report_month_grouping';

/** Hard cap on chain length — keeps multi-year anchors bounded */
private const MAX_CHAIN_MONTHS = 60;

Expand Down Expand Up @@ -460,6 +463,17 @@ public function budgetStartDay(string $userId): int {
return max(1, min(31, $startDay));
}

/**
* The day the months of the dashboard charts and the month-by-month
* reports start on: the budget start day when the user chose budget
* months for them in Settings, otherwise 1, calendar months (#457).
*/
public function reportMonthStartDay(string $userId): int {
return $this->settingService->get($userId, self::REPORT_MONTHS_SETTING) === 'budget'
? $this->budgetStartDay($userId)
: 1;
}

/**
* Today (Y-m-d) on the user's calendar, not the server's: in Sydney the
* server is still on last month until mid-morning on the 1st, and the
Expand Down
25 changes: 25 additions & 0 deletions budget/lib/Service/BudgetPeriod.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,31 @@ public static function monthContaining(string $date, int $startDay): string {
return substr($date, 0, 7);
}

/**
* The budget months (Y-m) a range touches, in order: from the one
* holding $start to the one holding $end. With start day 1 these are
* the calendar months. Empty when the range is reversed.
*
* @return list<string>
*/
public static function monthsBetween(string $start, string $end, int $startDay): array {
if ($start > $end) {
return [];
}
$months = [];
$month = \DateTime::createFromFormat('!Y-m', self::monthContaining($start, $startDay));
$last = self::monthContaining($end, $startDay);
// Bounded: a thousand years of months is more than any range asks for
for ($guard = 0; $guard < 12000; $guard++) {
$months[] = $month->format('Y-m');
if ($months[count($months) - 1] >= $last) {
break;
}
$month->modify('first day of next month');
}
return $months;
}

/**
* Budget amounts of any periods as one monthly total, by the yearly
* ratios the Budget page's summary uses (formatters.prorateBudget): each
Expand Down
Loading