mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-21 15:36:50 -05:00
Formula Card: Add budget analysis functions (#7078)
* feat(formula): Add QUERY_BUDGET function for budget-aware formula reporting * docs(release): Add QUERY_BUDGET feature release notes * refactor: decompose into multiple functions and support goal dimension * [autofix.ci] apply automated fixes * refactor: simplified code * [autofix.ci] apply automated fixes * updated release notes --------- Co-authored-by: Your Name <tomgriffin@localhost> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -188,6 +188,10 @@ const DATE_FUNCTIONS = new Set([
|
||||
const QUERY_FUNCTIONS = new Set([
|
||||
'QUERY',
|
||||
'QUERY_COUNT',
|
||||
'BUDGET_QUERY',
|
||||
'QUERY_EXTRACT_CATEGORIES',
|
||||
'QUERY_EXTRACT_TIMEFRAME_START',
|
||||
'QUERY_EXTRACT_TIMEFRAME_END',
|
||||
'LOOKUP',
|
||||
'VLOOKUP',
|
||||
'HLOOKUP',
|
||||
@@ -494,6 +498,50 @@ export function excelFormulaAutocomplete(
|
||||
apply: `QUERY_COUNT("${queryName}")`,
|
||||
boost: 14,
|
||||
},
|
||||
{
|
||||
label: `BUDGET_QUERY("budgeted", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
|
||||
type: 'function',
|
||||
section: '🔍 Query Functions',
|
||||
info: t(
|
||||
'Sum of budgeted amounts with extracted parameters from {{queryName}}.',
|
||||
{ queryName },
|
||||
),
|
||||
apply: `BUDGET_QUERY("budgeted", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
|
||||
boost: 13,
|
||||
},
|
||||
{
|
||||
label: `BUDGET_QUERY("spent", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
|
||||
type: 'function',
|
||||
section: '🔍 Query Functions',
|
||||
info: t(
|
||||
'Sum of spending with extracted parameters from {{queryName}}.',
|
||||
{ queryName },
|
||||
),
|
||||
apply: `BUDGET_QUERY("spent", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
|
||||
boost: 13,
|
||||
},
|
||||
{
|
||||
label: `BUDGET_QUERY("balance_start", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
|
||||
type: 'function',
|
||||
section: '🔍 Query Functions',
|
||||
info: t(
|
||||
'Opening balance with extracted parameters from {{queryName}}.',
|
||||
{ queryName },
|
||||
),
|
||||
apply: `BUDGET_QUERY("balance_start", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
|
||||
boost: 13,
|
||||
},
|
||||
{
|
||||
label: `BUDGET_QUERY("balance_end", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
|
||||
type: 'function',
|
||||
section: '🔍 Query Functions',
|
||||
info: t(
|
||||
'Closing balance with extracted parameters from {{queryName}}.',
|
||||
{ queryName },
|
||||
),
|
||||
apply: `BUDGET_QUERY("balance_end", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
|
||||
boost: 13,
|
||||
},
|
||||
])
|
||||
: [];
|
||||
|
||||
|
||||
@@ -116,6 +116,54 @@ export const queryModeFunctions: Record<string, FunctionDef> = {
|
||||
{ name: 'decimals', description: 'Decimals' },
|
||||
],
|
||||
},
|
||||
BUDGET_QUERY: {
|
||||
name: 'BUDGET_QUERY',
|
||||
description: t(
|
||||
'Evaluate a budget query using extracted parameters. Supply dimension, categories, and timeframe explicitly.',
|
||||
),
|
||||
parameters: [
|
||||
{
|
||||
name: 'dimension',
|
||||
description:
|
||||
'One of: budgeted, spent, balance_start, balance_end, goal (string)',
|
||||
},
|
||||
{
|
||||
name: 'categories',
|
||||
description:
|
||||
'Categories result from QUERY_EXTRACT_CATEGORIES() (array)',
|
||||
},
|
||||
{
|
||||
name: 'timeframe_start',
|
||||
description:
|
||||
'Start month from QUERY_EXTRACT_TIMEFRAME_START() (string)',
|
||||
},
|
||||
{
|
||||
name: 'timeframe_end',
|
||||
description: 'End month from QUERY_EXTRACT_TIMEFRAME_END() (string)',
|
||||
},
|
||||
],
|
||||
},
|
||||
QUERY_EXTRACT_CATEGORIES: {
|
||||
name: 'QUERY_EXTRACT_CATEGORIES',
|
||||
description: t('Extract category IDs from a named query.'),
|
||||
parameters: [
|
||||
{ name: 'queryName', description: 'Name of the saved query (string)' },
|
||||
],
|
||||
},
|
||||
QUERY_EXTRACT_TIMEFRAME_START: {
|
||||
name: 'QUERY_EXTRACT_TIMEFRAME_START',
|
||||
description: t('Extract the start month from a named query timeframe.'),
|
||||
parameters: [
|
||||
{ name: 'queryName', description: 'Name of the saved query (string)' },
|
||||
],
|
||||
},
|
||||
QUERY_EXTRACT_TIMEFRAME_END: {
|
||||
name: 'QUERY_EXTRACT_TIMEFRAME_END',
|
||||
description: t('Extract the end month from a named query timeframe.'),
|
||||
parameters: [
|
||||
{ name: 'queryName', description: 'Name of the saved query (string)' },
|
||||
],
|
||||
},
|
||||
FLOOR: {
|
||||
name: 'FLOOR',
|
||||
description: t('Rounds down to nearest multiple of significance.'),
|
||||
|
||||
@@ -7,11 +7,16 @@ import * as monthUtils from 'loot-core/shared/months';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import type { Query } from 'loot-core/shared/query';
|
||||
import { integerToAmount } from 'loot-core/shared/util';
|
||||
import type { RuleConditionEntity, TimeFrame } from 'loot-core/types/models';
|
||||
import type {
|
||||
CategoryEntity,
|
||||
RuleConditionEntity,
|
||||
TimeFrame,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { useLocale } from './useLocale';
|
||||
|
||||
import { getLiveRange } from '@desktop-client/components/reports/getLiveRange';
|
||||
import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
|
||||
|
||||
type QueryConfig = {
|
||||
conditions?: RuleConditionEntity[];
|
||||
@@ -25,6 +30,58 @@ function escapeRegExp(s: string) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// Parse a BUDGET_QUERY parameter - can be extraction function, array literal, or string
|
||||
function parseBudgetParam(
|
||||
param: string,
|
||||
): { type: 'extraction' | 'literal'; data: unknown } | null {
|
||||
param = param.trim();
|
||||
|
||||
// Try extraction function: QUERY_EXTRACT_*("queryName")
|
||||
const extractMatch = param.match(
|
||||
/^(QUERY_EXTRACT_\w+)\s*\(\s*["']([^"']+)["']\s*\)$/,
|
||||
);
|
||||
if (extractMatch) {
|
||||
return {
|
||||
type: 'extraction',
|
||||
data: { funcName: extractMatch[1], queryName: extractMatch[2] },
|
||||
};
|
||||
}
|
||||
|
||||
// Try array literal: {"id1";"id2";...}
|
||||
const arrayMatch = param.match(/^\{([^}]*)\}$/);
|
||||
if (arrayMatch) {
|
||||
const items = arrayMatch[1]
|
||||
.split(';')
|
||||
.map(item => item.replace(/^["']|["']$/g, '').trim())
|
||||
.filter(item => item.length > 0);
|
||||
return { type: 'literal', data: items };
|
||||
}
|
||||
|
||||
// Try string literal: "value"
|
||||
const stringMatch = param.match(/^["']([^"']*)["']$/);
|
||||
if (stringMatch) {
|
||||
return { type: 'literal', data: stringMatch[1] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve a parsed BUDGET_QUERY parameter to its actual value
|
||||
function resolveBudgetParam(
|
||||
parsed: ReturnType<typeof parseBudgetParam>,
|
||||
extractionResults: Record<string, Record<string, unknown>>,
|
||||
): unknown {
|
||||
if (!parsed || parsed.type === 'literal') {
|
||||
return parsed?.data;
|
||||
}
|
||||
|
||||
const { funcName, queryName } = parsed.data as {
|
||||
funcName: string;
|
||||
queryName: string;
|
||||
};
|
||||
return extractionResults[funcName]?.[`${funcName}(${queryName})`];
|
||||
}
|
||||
|
||||
export function useFormulaExecution(
|
||||
formula: string,
|
||||
queries: QueriesMap,
|
||||
@@ -70,6 +127,66 @@ export function useFormulaExecution(
|
||||
new Set(queryCountMatches.map(m => m[1])),
|
||||
);
|
||||
|
||||
// Extract QUERY_EXTRACT_* calls for evaluation
|
||||
// These extraction functions are used as parameters to BUDGET_QUERY
|
||||
const extractionFunctions = {
|
||||
QUERY_EXTRACT_CATEGORIES:
|
||||
/QUERY_EXTRACT_CATEGORIES\s*\(\s*["']([^"']+)["']\s*\)/gi,
|
||||
QUERY_EXTRACT_TIMEFRAME_START:
|
||||
/QUERY_EXTRACT_TIMEFRAME_START\s*\(\s*["']([^"']+)["']\s*\)/gi,
|
||||
QUERY_EXTRACT_TIMEFRAME_END:
|
||||
/QUERY_EXTRACT_TIMEFRAME_END\s*\(\s*["']([^"']+)["']\s*\)/gi,
|
||||
};
|
||||
|
||||
// Store extraction results
|
||||
const extractionResults: Record<string, Record<string, unknown>> = {
|
||||
QUERY_EXTRACT_CATEGORIES: {},
|
||||
QUERY_EXTRACT_TIMEFRAME_START: {},
|
||||
QUERY_EXTRACT_TIMEFRAME_END: {},
|
||||
};
|
||||
|
||||
// Evaluate extraction functions first
|
||||
for (const [funcName, regex] of Object.entries(extractionFunctions)) {
|
||||
const matches = Array.from(formula.matchAll(regex));
|
||||
for (const match of matches) {
|
||||
const queryName = match[1];
|
||||
const key = `${funcName}(${queryName})`;
|
||||
|
||||
if (!extractionResults[funcName][key]) {
|
||||
try {
|
||||
if (funcName === 'QUERY_EXTRACT_CATEGORIES') {
|
||||
extractionResults[funcName][key] =
|
||||
await extractQueryCategories(queryName, queries);
|
||||
} else if (funcName === 'QUERY_EXTRACT_TIMEFRAME_START') {
|
||||
extractionResults[funcName][key] =
|
||||
await extractQueryTimeframeStart(queryName, queries);
|
||||
} else if (funcName === 'QUERY_EXTRACT_TIMEFRAME_END') {
|
||||
extractionResults[funcName][key] =
|
||||
await extractQueryTimeframeEnd(queryName, queries);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Error evaluating ${funcName}(${queryName})`,
|
||||
err,
|
||||
);
|
||||
extractionResults[funcName][key] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match BUDGET_QUERY(dimension, param1, param2, param3) where each param can be:
|
||||
// extraction function, array literal {...}, or string "..."
|
||||
const paramPattern = String.raw`(?:QUERY_EXTRACT_\w+\s*\([^)]*\)|\{[^}]*\}|["'][^"']*["'])`;
|
||||
const budgetMatches = Array.from(
|
||||
formula.matchAll(
|
||||
new RegExp(
|
||||
`BUDGET_QUERY\\s*\\(\\s*["']([^"']+)["']\\s*,\\s*(${paramPattern})\\s*,\\s*(${paramPattern})\\s*,\\s*(${paramPattern})\\s*\\)`,
|
||||
'gi',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
for (const queryName of queryNames) {
|
||||
const queryConfig = queries[queryName];
|
||||
|
||||
@@ -113,6 +230,100 @@ export function useFormulaExecution(
|
||||
processedFormula = processedFormula.replace(regex, String(value));
|
||||
}
|
||||
|
||||
// Process BUDGET_QUERY BEFORE replacing extraction functions
|
||||
// This ensures we match BUDGET_QUERY with extraction functions still intact
|
||||
if (budgetMatches.length > 0) {
|
||||
for (const match of budgetMatches) {
|
||||
const dimension = match[1];
|
||||
const param1Str = match[2].trim();
|
||||
const param2Str = match[3].trim();
|
||||
const param3Str = match[4].trim();
|
||||
|
||||
try {
|
||||
// Parse and resolve parameters
|
||||
const param1 = resolveBudgetParam(
|
||||
parseBudgetParam(param1Str),
|
||||
extractionResults,
|
||||
);
|
||||
const param2 = resolveBudgetParam(
|
||||
parseBudgetParam(param2Str),
|
||||
extractionResults,
|
||||
);
|
||||
const param3 = resolveBudgetParam(
|
||||
parseBudgetParam(param3Str),
|
||||
extractionResults,
|
||||
);
|
||||
|
||||
// Validate resolved parameters
|
||||
if (
|
||||
!Array.isArray(param1) ||
|
||||
typeof param2 !== 'string' ||
|
||||
typeof param3 !== 'string'
|
||||
) {
|
||||
console.error(
|
||||
'Failed to resolve BUDGET_QUERY parameters:',
|
||||
param1Str,
|
||||
param2Str,
|
||||
param3Str,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Evaluate BUDGET_QUERY
|
||||
const val = await fetchBudgetDimensionValueDirect(
|
||||
dimension,
|
||||
param1 as string[],
|
||||
param2 as string,
|
||||
param3 as string,
|
||||
);
|
||||
|
||||
processedFormula = processedFormula.replace(
|
||||
match[0],
|
||||
String(val),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error evaluating BUDGET_QUERY', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NOW replace remaining QUERY_EXTRACT_* functions with their evaluated values
|
||||
// (any that weren't consumed by BUDGET_QUERY)
|
||||
for (const [funcName, regex] of Object.entries(extractionFunctions)) {
|
||||
const matches = Array.from(processedFormula.matchAll(regex));
|
||||
for (const match of matches) {
|
||||
const queryName = match[1];
|
||||
const key = `${funcName}(${queryName})`;
|
||||
const value = extractionResults[funcName][key];
|
||||
|
||||
if (value !== null && value !== undefined) {
|
||||
const escapedQueryName = escapeRegExp(queryName);
|
||||
const replacementRegex = new RegExp(
|
||||
`${funcName}\\s*\\(\\s*["']${escapedQueryName}["']\\s*\\)`,
|
||||
'gi',
|
||||
);
|
||||
|
||||
// Format the replacement value based on type
|
||||
let replacement: string;
|
||||
if (Array.isArray(value)) {
|
||||
// For arrays, convert to HyperFormula array literal
|
||||
replacement = `{${value.map(v => `"${v}"`).join(';')}}`;
|
||||
} else if (typeof value === 'string') {
|
||||
// For strings, wrap in quotes
|
||||
replacement = `"${value}"`;
|
||||
} else {
|
||||
// For numbers
|
||||
replacement = String(value);
|
||||
}
|
||||
|
||||
processedFormula = processedFormula.replace(
|
||||
replacementRegex,
|
||||
replacement,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create HyperFormula instance
|
||||
hfInstance = HyperFormula.buildEmpty({
|
||||
licenseKey: 'gpl-v3',
|
||||
@@ -347,3 +558,267 @@ async function fetchQueryCount(config: QueryConfig): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Extract category-based conditions (ignore transaction-specific filters)
|
||||
function extractCategoryConditions(
|
||||
conditions: RuleConditionEntity[],
|
||||
): RuleConditionEntity[] {
|
||||
return conditions.filter(
|
||||
cond => !cond.customName && cond.field === 'category',
|
||||
);
|
||||
}
|
||||
|
||||
// Helper: Evaluate category conditions to get matching categories
|
||||
async function getCategoriesFromConditions(
|
||||
allCategories: CategoryEntity[],
|
||||
conditions: RuleConditionEntity[],
|
||||
conditionsOp: 'and' | 'or',
|
||||
): Promise<string[]> {
|
||||
if (conditions.length === 0) {
|
||||
// No category filter: include all non-income, non-hidden categories
|
||||
return allCategories
|
||||
.filter((cat: CategoryEntity) => !cat.is_income && !cat.hidden)
|
||||
.map((cat: CategoryEntity) => cat.id);
|
||||
}
|
||||
|
||||
// Evaluate each condition to get sets of matching categories
|
||||
const conditionResults = conditions.map(cond => {
|
||||
const matching = allCategories.filter((cat: CategoryEntity) => {
|
||||
if (cond.op === 'is') {
|
||||
return cond.value === cat.id;
|
||||
} else if (cond.op === 'isNot') {
|
||||
return cond.value !== cat.id;
|
||||
} else if (cond.op === 'oneOf') {
|
||||
return cond.value.includes(cat.id);
|
||||
} else if (cond.op === 'notOneOf') {
|
||||
return !cond.value.includes(cat.id);
|
||||
} else if (cond.op === 'contains') {
|
||||
return cat.name.includes(cond.value as string);
|
||||
} else if (cond.op === 'doesNotContain') {
|
||||
return !cat.name.includes(cond.value as string);
|
||||
} else if (cond.op === 'matches') {
|
||||
try {
|
||||
return new RegExp(cond.value as string).test(cat.name);
|
||||
} catch (e) {
|
||||
console.warn('Invalid regexp in matches condition', e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Unknown operator: include category by default and log warning
|
||||
console.warn(`Unknown category condition operator: ${cond.op}`);
|
||||
return true;
|
||||
});
|
||||
return matching.map((cat: CategoryEntity) => cat.id);
|
||||
});
|
||||
|
||||
if (conditionsOp === 'or') {
|
||||
// OR: Union of all matching categories
|
||||
const categoryIds = new Set(conditionResults.flat());
|
||||
return Array.from(categoryIds);
|
||||
} else {
|
||||
// AND: Intersection of all matching categories
|
||||
if (conditionResults.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const firstSet = new Set(conditionResults[0]);
|
||||
for (let i = 1; i < conditionResults.length; i++) {
|
||||
const currentIds = new Set(conditionResults[i]);
|
||||
// Keep only categories that are in both sets
|
||||
const toRemove: string[] = [];
|
||||
firstSet.forEach(id => {
|
||||
if (!currentIds.has(id)) {
|
||||
toRemove.push(id);
|
||||
}
|
||||
});
|
||||
toRemove.forEach(id => firstSet.delete(id));
|
||||
}
|
||||
return Array.from(firstSet);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Get month data from envelope-budget-month RPC
|
||||
async function getMonthBudgetData(
|
||||
month: string,
|
||||
): Promise<Array<{ name: string; value: string | number | boolean }>> {
|
||||
const monthData = await send('envelope-budget-month', { month });
|
||||
return monthData || [];
|
||||
}
|
||||
|
||||
// Helper: Extract value from month data by field pattern
|
||||
function getMonthDataValue(
|
||||
monthData: Array<{ name: string; value: string | number | boolean }>,
|
||||
pattern: string,
|
||||
catId: string,
|
||||
): string | number | boolean {
|
||||
const fieldName = pattern.replace('{catId}', catId);
|
||||
const cell = monthData.find(c => c.name.endsWith(fieldName));
|
||||
return cell?.value ?? 0;
|
||||
}
|
||||
|
||||
// Helper: Extract categories from a named query (for QUERY_EXTRACT_CATEGORIES)
|
||||
async function extractQueryCategories(
|
||||
queryName: string,
|
||||
queries: QueriesMap,
|
||||
): Promise<string[]> {
|
||||
const queryConfig = queries[queryName];
|
||||
if (!queryConfig) {
|
||||
console.warn(`Query "${queryName}" not found in queries config`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const categoryConditions = extractCategoryConditions(
|
||||
queryConfig.conditions || [],
|
||||
);
|
||||
const { list: allCategories } = await send('get-categories');
|
||||
return getCategoriesFromConditions(
|
||||
allCategories,
|
||||
categoryConditions,
|
||||
queryConfig.conditionsOp || 'and',
|
||||
);
|
||||
}
|
||||
|
||||
// Helper: Extract timeframe start month from a named query (for QUERY_EXTRACT_TIMEFRAME_START)
|
||||
async function extractQueryTimeframeStart(
|
||||
queryName: string,
|
||||
queries: QueriesMap,
|
||||
): Promise<string> {
|
||||
const queryConfig = queries[queryName];
|
||||
if (!queryConfig || !queryConfig.timeFrame) {
|
||||
console.warn(
|
||||
`Query "${queryName}" not found or has no timeframe; cannot extract start`,
|
||||
);
|
||||
return monthUtils.currentMonth();
|
||||
}
|
||||
|
||||
const [startMonth] = calculateTimeRange(queryConfig.timeFrame);
|
||||
return startMonth;
|
||||
}
|
||||
|
||||
// Helper: Extract timeframe end month from a named query (for QUERY_EXTRACT_TIMEFRAME_END)
|
||||
async function extractQueryTimeframeEnd(
|
||||
queryName: string,
|
||||
queries: QueriesMap,
|
||||
): Promise<string> {
|
||||
const queryConfig = queries[queryName];
|
||||
if (!queryConfig || !queryConfig.timeFrame) {
|
||||
console.warn(
|
||||
`Query "${queryName}" not found or has no timeframe; cannot extract end`,
|
||||
);
|
||||
return monthUtils.currentMonth();
|
||||
}
|
||||
|
||||
const [, endMonth] = calculateTimeRange(queryConfig.timeFrame);
|
||||
return endMonth;
|
||||
}
|
||||
|
||||
// Helper: Evaluate budget dimension with already-extracted parameters (used by compositional BUDGET_QUERY)
|
||||
async function fetchBudgetDimensionValueDirect(
|
||||
dimension: string,
|
||||
categoryIds: string[],
|
||||
startMonth: string,
|
||||
endMonth: string,
|
||||
): Promise<number> {
|
||||
const allowed = new Set([
|
||||
'budgeted',
|
||||
'spent',
|
||||
'balance_start',
|
||||
'balance_end',
|
||||
'goal',
|
||||
]);
|
||||
const dim = dimension.toLowerCase();
|
||||
if (!allowed.has(dim)) {
|
||||
throw new Error(`Invalid BUDGET_QUERY dimension: ${dimension}`);
|
||||
}
|
||||
|
||||
const intervals = monthUtils.rangeInclusive(startMonth, endMonth);
|
||||
|
||||
// Helper: sum a dimension across all months/categories
|
||||
const sumDimension = async (fieldPattern: string): Promise<number> => {
|
||||
let total = 0;
|
||||
for (const month of intervals) {
|
||||
const monthData = await getMonthBudgetData(month);
|
||||
for (const catId of categoryIds) {
|
||||
total += getMonthDataValue(monthData, fieldPattern, catId) as number;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
if (dim === 'budgeted') {
|
||||
return integerToAmount(await sumDimension('budget-{catId}'), 2);
|
||||
}
|
||||
|
||||
if (dim === 'spent') {
|
||||
return integerToAmount(await sumDimension('sum-amount-{catId}'), 2);
|
||||
}
|
||||
|
||||
if (dim === 'goal') {
|
||||
return integerToAmount(await sumDimension('goal-{catId}'), 2);
|
||||
}
|
||||
|
||||
// Handle balance dimensions: chain month-by-month with carryover logic
|
||||
if (dim === 'balance_start' || dim === 'balance_end') {
|
||||
let runningBalance = 0;
|
||||
const monthBeforeStart = monthUtils.subMonths(startMonth, 1);
|
||||
const prevMonthData = await getMonthBudgetData(monthBeforeStart);
|
||||
|
||||
for (const catId of categoryIds) {
|
||||
const catBalance = getMonthDataValue(
|
||||
prevMonthData,
|
||||
'leftover-{catId}',
|
||||
catId,
|
||||
) as number;
|
||||
const hasCarryover = Boolean(
|
||||
getMonthDataValue(prevMonthData, 'carryover-{catId}', catId),
|
||||
);
|
||||
if (catBalance > 0 || (catBalance < 0 && hasCarryover)) {
|
||||
runningBalance += catBalance;
|
||||
}
|
||||
}
|
||||
|
||||
const balances: Record<string, { start: number; end: number }> = {};
|
||||
|
||||
for (const month of intervals) {
|
||||
const monthData = await getMonthBudgetData(month);
|
||||
let budgeted = 0;
|
||||
let spent = 0;
|
||||
let carryoverToNextMonth = 0;
|
||||
|
||||
for (const catId of categoryIds) {
|
||||
const catBudgeted =
|
||||
Number(getMonthDataValue(monthData, 'budget-{catId}', catId)) || 0;
|
||||
const catSpent =
|
||||
Number(getMonthDataValue(monthData, 'sum-amount-{catId}', catId)) ||
|
||||
0;
|
||||
const catBalance =
|
||||
Number(getMonthDataValue(monthData, 'leftover-{catId}', catId)) || 0;
|
||||
const hasCarryover = Boolean(
|
||||
getMonthDataValue(monthData, 'carryover-{catId}', catId),
|
||||
);
|
||||
|
||||
budgeted += catBudgeted;
|
||||
spent += catSpent;
|
||||
|
||||
if (catBalance > 0 || (catBalance < 0 && hasCarryover)) {
|
||||
carryoverToNextMonth += catBalance;
|
||||
}
|
||||
}
|
||||
|
||||
const balanceStart = runningBalance;
|
||||
const balanceEnd = budgeted + spent + runningBalance;
|
||||
|
||||
balances[month] = { start: balanceStart, end: balanceEnd };
|
||||
runningBalance = carryoverToNextMonth;
|
||||
}
|
||||
|
||||
if (dim === 'balance_start') {
|
||||
return integerToAmount(balances[intervals[0]]?.start || 0, 2);
|
||||
}
|
||||
return integerToAmount(
|
||||
balances[intervals[intervals.length - 1]]?.end || 0,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user