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
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: 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
27 changes: 27 additions & 0 deletions budget/lib/Db/TransactionMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,33 @@ public function findAllByUserAndDateRange(string $userId, string $startDate, str
return $this->findEntities($qb);
}

/**
* The date of each account's first transaction (Y-m-d), keyed by account
* id. Accounts without transactions are left out. The forecast reads it
* to know from when an account's history is complete.
*
* @param int[] $accountIds
* @return array<int, string>
*/
public function getFirstTransactionDates(array $accountIds): array {
if ($accountIds === []) {
return [];
}
$qb = $this->db->getQueryBuilder();
$qb->select('account_id')
->selectAlias($qb->func()->min('date'), 'first_date')
->from($this->getTableName())
->where($qb->expr()->in('account_id', $qb->createNamedParameter(array_map('intval', $accountIds), IQueryBuilder::PARAM_INT_ARRAY)))
->groupBy('account_id');
$result = $qb->executeQuery();
$dates = [];
while ($row = $result->fetch()) {
$dates[(int)$row['account_id']] = substr((string)$row['first_date'], 0, 10);
}
$result->closeCursor();
return $dates;
}

/**
* Whether any transaction or split part, in anyone's account, uses this
* category. Deliberately unscoped: a shared category can be used from the
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
7 changes: 5 additions & 2 deletions budget/lib/Service/Forecast/PatternAnalyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use OCA\Budget\Db\CategoryMapper;
use OCA\Budget\Db\TransactionSplitMapper;
use OCA\Budget\Service\BudgetPeriod;
use OCA\Budget\Service\MoneyCalculator;

/**
Expand Down Expand Up @@ -173,13 +174,15 @@ public static function inForecastCurrency(float $amount, $transaction, array $ac
* @param array<int, string> $accountRates account id => multiplier into the
* forecast's currency (CurrencyTotals::accountRates()); empty
* when the accounts share one currency
* @param int $monthStartDay 1 for calendar months, else the day budget
* months start on (#457)
* @return array Monthly income/expense totals
*/
public function aggregateMonthlyData(array $transactions, array $accountRates = []): array {
public function aggregateMonthlyData(array $transactions, array $accountRates = [], int $monthStartDay = 1): array {
$monthlyData = [];

foreach ($transactions as $transaction) {
$month = date('Y-m', strtotime($transaction->getDate()));
$month = BudgetPeriod::monthContaining(substr((string)$transaction->getDate(), 0, 10), $monthStartDay);

if (!isset($monthlyData[$month])) {
$monthlyData[$month] = ['income' => 0.0, 'expenses' => 0.0];
Expand Down
Loading