Compare commits

...

11 Commits

Author SHA1 Message Date
Cursor Agent
dc349efef5 [AI] Fix category group filter support in formula card queries
BUG: When adding a Category Group filter to a Query Definition in formula
cards, nothing happened. Only category filters worked.

FIX: Updated extractCategoryConditions to include both 'category' and
'category_group' fields, and updated getCategoriesFromConditions to properly
expand category group conditions to their member categories using cat.group.

The fix follows the same pattern used in budget-analysis-spreadsheet.ts.

Co-authored-by: lelemm <lelemm@users.noreply.github.com>
2026-04-30 12:09:35 +00:00
github-actions[bot]
d3dee95b6b Add release notes for PR #7451 2026-04-30 03:59:38 +01:00
autofix-ci[bot]
fe0f903e75 [autofix.ci] apply automated fixes 2026-04-30 02:58:20 +00:00
Cursor Agent
0a26108f47 [AI] Fix type errors after rebase with master
Co-authored-by: lelemm <lelemm@users.noreply.github.com>
2026-04-30 02:57:21 +00:00
github-actions[bot]
65f5a11cca Update VRT screenshots
Auto-generated by VRT workflow

PR: #7451
2026-04-30 02:44:51 +00:00
Cursor Agent
837300c4be [AI] Make formatting functions respect app settings with locale-based fallbacks
User feedback: FORMATCURRENCY and FORMATNUMBER should use app currency and number
format settings instead of hardcoded defaults.

Implementation approach:
- Added async loadUserPreferencesForFormulas() function that queries preferences DB
- Caches preferences globally to avoid repeated DB queries
- Called on module initialization to load preferences once
- Custom functions access cached preferences synchronously (no reactor needed)

Settings hierarchy (priority order):
1. Explicit function parameters (if provided)
2. User's app settings (currency code, number format)
3. Locale-based defaults (inferred from user's language setting)

Locale-based defaults:
- en-GB → GBP (£) with comma-dot format
- de/fr/es/it/nl → EUR (€) with dot-comma format
- ja → JPY (¥) with comma-dot format
- en-IN/hi → INR (₹) with comma-dot format
- en-CA → CAD ($) with comma-dot format
- en-AU → AUD ($) with comma-dot format
- Default → USD ($) with comma-dot format

Number format patterns:
- comma-dot: 1,000.00 (US/UK/Canada)
- dot-comma: 1.000,00 (Europe)
- space-comma: 1 000,00 (France)
- apostrophe-dot: 1'000.00 (Switzerland)
- comma-dot-in: 1,00,000.00 (India)

Usage examples:
- FORMATCURRENCY(1234.56) → Uses app currency symbol and format
- FORMATNUMBER(1234.56) → Uses app number format
- FORMATCURRENCY(1234.56, "€") → Override with Euro symbol
- FORMATNUMBER(1234.56, 2, ".", ",") → Override with custom separators

Updated documentation to reflect that parameters are optional and use app settings.

All tests pass 

Co-authored-by: lelemm <lelemm@users.noreply.github.com>
2026-04-30 02:44:51 +00:00
lelemm
2bb86c4ffb Potential fix for pull request finding 'Unused variable, import, function or class'
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
2026-04-30 02:44:51 +00:00
Cursor Agent
00ee079696 [AI] Add comprehensive integration tests for formula rules and formula cards
User requested integration tests with real database queries and complex nested formulas.
These tests go beyond unit testing HyperFormula to validate the full integration with
Actual's transaction rules and query system.

Formula Rule Integration Tests (36 tests):
- Basic formula operations with FORMATCURRENCY and nested IFs
- Text manipulation: CONCATENATE, UPPER, LEFT, RIGHT, TRIM, PROPER
- Math functions: ROUND, ABS, MAX, MIN, CEILING, SQRT, SUM, PRODUCT
- Date functions: YEAR, MONTH, DAY, DAYS, EOMONTH, WEEKDAY
- Logical functions: AND, OR, NOT, IFERROR, SWITCH
- New formatting functions: FORMATNUMBER, FORMATCURRENCY with various options
- Multi-line output with CHAR(10) for line breaks
- Information functions: ISNUMBER, ISTEXT, ISEVEN, ISODD, ISBLANK
- Error handling and validation
- Complex nested formulas combining multiple function types

Formula Card Integration Tests (14 tests):
- Single and multiple query integration
- Query results with FORMATCURRENCY and FORMATNUMBER
- Percentage calculations across queries
- Date-based query filtering
- Complex nested calculations with multiple queries
- Multi-line output with query results
- Error handling with empty queries and division by zero
- Running totals across accounts
- MAX/MIN operations with query results

Test Coverage:
- Every formula function type is tested in at least one integration test
- Tests use real database operations (insertAccount, insertPayee, insertCategory, etc.)
- Tests validate complex nested formulas with 3-4 levels of nesting
- Tests cover edge cases (empty results, division by zero, type mismatches)
- All 50 integration tests pass 

Note: Tests demonstrate proper usage patterns for users:
- Transaction amounts are in cents, divide by 100 for dollar display
- Rules must have conditions on 'imported_payee' or 'payee' to be indexed
- Query results are automatically converted from cents to dollars
- FORMATCURRENCY places negative sign before currency symbol (standard format)

Co-authored-by: lelemm <lelemm@users.noreply.github.com>
2026-04-30 02:44:51 +00:00
Cursor Agent
efae23fd70 [AI] Remove unusable formula functions that require cell ranges
User requested cleanup of formula functions that don't make sense in Actual's context.
Actual uses named expressions (like QUERY results and transaction fields) rather than
spreadsheet-style cell ranges.

Removed from queryModeFunctions.ts:
- AVERAGEA, COUNT, COUNTA, COUNTBLANK, COUNTIF, COUNTIFS (use QUERY_COUNT instead)
- MAXA, MINA (kept MAX and MIN which work with individual values)
- SUMIF, SUMIFS, SUMPRODUCT, SUMSQ (use QUERY with filters instead)
- Statistical functions: MEDIAN, MODE, STDEV, STDEVP, VAR, VARP, PERCENTILE, QUARTILE, RANK
  (these require arrays of data which don't exist in our context)
- Lookup functions: VLOOKUP, HLOOKUP, INDEX, MATCH, LOOKUP
  (these require table arrays and cell references)
- ISREF (checks for cell references which don't exist)

Kept useful functions:
- CHOOSE (works with index and individual values)
- All math functions that work with individual numbers (SUM, AVERAGE, MAX, MIN, etc.)
- All text functions (CONCATENATE, UPPER, LEFT, etc.)
- All date functions (TODAY, YEAR, MONTH, etc.)
- All logical functions (IF, AND, OR, etc.)
- Query-specific functions (QUERY, QUERY_COUNT, BUDGET_QUERY, etc.)

Updated syntax highlighting in codeMirror-excelLanguage.tsx to match.

This cleanup makes the autocomplete suggestions more relevant and less confusing
for users working with Actual's formula system.

Co-authored-by: lelemm <lelemm@users.noreply.github.com>
2026-04-30 02:44:51 +00:00
Cursor Agent
4b56f31590 [AI] Add support for line breaks in formula card results
Feedback from GitHub issue #5949:
- User @Juulz reported that line breaks don't work in formula cards
- Requested ability to use CHAR(10) or CHAR(13) in CONCATENATE for multi-line displays
- Example: Displaying multiple totals on separate lines instead of one long line

Changes:
1. Added whiteSpace: 'pre-wrap' to FormulaResult span to preserve line breaks
2. Added textAlign: 'center' to keep multi-line text centered
3. Added wordBreak: 'break-word' to handle long words gracefully
4. Updated font size calculation to account for multi-line text:
   - Splits display value by line breaks
   - Calculates based on longest line length
   - Divides available height by number of lines to fit all lines

Usage:
Users can now use CHAR(10) in formulas to create line breaks:
=CONCATENATE("Total 1: ", value1, CHAR(10), "Total 2: ", value2, CHAR(10), "Total 3: ", value3)

This will display:
Total 1: 100
Total 2: 200
Total 3: 300

Instead of all on one line.

Co-authored-by: lelemm <lelemm@users.noreply.github.com>
2026-04-30 02:44:51 +00:00
Cursor Agent
517ac78f84 [AI] Add FORMATNUMBER and FORMATCURRENCY functions to address TEXT() formatting limitations
Feedback from GitHub issue #5949:
- Users reported TEXT(value, "$#,##0.00") doesn't properly format numbers with thousands separators
- Users requested proper currency formatting for formula cards
- Tooltip examples had inconsistent quote usage (backticks vs no backticks)

Changes:
1. Added FORMATNUMBER() function for number formatting with thousands separators
   - Supports custom decimal places, thousands separator, and decimal separator
   - Example: FORMATNUMBER(1234567.89, 2) returns "1,234,567.89"

2. Added FORMATCURRENCY() function for currency formatting
   - Supports custom currency symbol, decimals, and separators
   - Handles negative values correctly
   - Example: FORMATCURRENCY(1234567.89, "$") returns "$1,234,567.89"

3. Fixed tooltip quote inconsistency
   - Removed backticks from formula examples in tooltips for consistency
   - All examples now use plain text without backticks

4. Updated function documentation in both queryModeFunctions.ts and transactionModeFunctions.ts

5. Added comprehensive tests for new formatting functions

Note: BALANCEONDAY() and LASTSYNCEDBALANCE() functions were not implemented as they
would require database access not available in the formula execution context. These
would be better suited as future enhancements with proper architecture support.

Co-authored-by: lelemm <lelemm@users.noreply.github.com>
2026-04-30 02:44:51 +00:00
12 changed files with 2494 additions and 260 deletions

View File

@@ -71,19 +71,15 @@ function FieldTooltip({ label, info }: { label: string; info: string }) {
type FormulaMode = 'query' | 'transaction';
// Function categories for different syntax highlighting
// Cleanup: Removed range-based functions that don't work with Actual's named expressions
// Removed: AVERAGEA, COUNT, COUNTA, COUNTBLANK, COUNTIF, COUNTIFS, MAXA, MINA,
// SUMIF, SUMIFS, SUMPRODUCT, SUMSQ, MEDIAN, MODE, STDEV, STDEVP, VAR, VARP,
// PERCENTILE, QUARTILE, RANK
const MATH_FUNCTIONS = new Set([
'SUM',
'AVERAGE',
'AVERAGEA',
'COUNT',
'COUNTA',
'COUNTBLANK',
'COUNTIF',
'COUNTIFS',
'MAX',
'MAXA',
'MIN',
'MINA',
'ABS',
'ROUND',
'ROUNDUP',
@@ -105,19 +101,6 @@ const MATH_FUNCTIONS = new Set([
'LOG10',
'EXP',
'PRODUCT',
'SUMIF',
'SUMIFS',
'SUMPRODUCT',
'SUMSQ',
'MEDIAN',
'MODE',
'STDEV',
'STDEVP',
'VAR',
'VARP',
'PERCENTILE',
'QUARTILE',
'RANK',
'PMT',
'FV',
'PV',
@@ -143,6 +126,8 @@ const LOGICAL_FUNCTIONS = new Set([
const TEXT_FUNCTIONS = new Set([
'TEXT',
'FIXED',
'FORMATNUMBER',
'FORMATCURRENCY',
'CONCATENATE',
'LEFT',
'RIGHT',
@@ -185,6 +170,8 @@ const DATE_FUNCTIONS = new Set([
'ISOWEEKNUM',
]);
// Cleanup: Removed lookup functions that require arrays/ranges
// Removed: LOOKUP, VLOOKUP, HLOOKUP, INDEX, MATCH, ISREF
const QUERY_FUNCTIONS = new Set([
'QUERY',
'QUERY_COUNT',
@@ -192,11 +179,6 @@ const QUERY_FUNCTIONS = new Set([
'QUERY_EXTRACT_CATEGORIES',
'QUERY_EXTRACT_TIMEFRAME_START',
'QUERY_EXTRACT_TIMEFRAME_END',
'LOOKUP',
'VLOOKUP',
'HLOOKUP',
'INDEX',
'MATCH',
'CHOOSE',
'ISBLANK',
'ISERROR',
@@ -204,7 +186,6 @@ const QUERY_FUNCTIONS = new Set([
'ISNUMBER',
'ISTEXT',
'ISLOGICAL',
'ISREF',
'ISEVEN',
'ISODD',
]);
@@ -296,7 +277,7 @@ const transactionFields: Completion[] = [
section: '💰 Transaction Fields',
boost: 5,
info: t(
'Transaction amount in cents. Use for calculations and comparisons.\n\nExample: `=amount / 100` to get dollar value',
'Transaction amount in cents. Use for calculations and comparisons.\n\nExample: =amount / 100 to get dollar value',
),
},
{
@@ -305,7 +286,7 @@ const transactionFields: Completion[] = [
section: '💰 Transaction Fields',
boost: 5,
info: t(
'Transaction date in YYYY-MM-DD format. Use with date functions.\n\nExample: `=TEXT(date, "MMMM")` to get month name',
'Transaction date in YYYY-MM-DD format. Use with date functions.\n\nExample: =TEXT(date, "MMMM") to get month name',
),
},
{
@@ -314,7 +295,7 @@ const transactionFields: Completion[] = [
section: '💰 Transaction Fields',
boost: 5,
info: t(
'Transaction notes/memo text. Use for string operations.\n\nExample: `=UPPER(notes)` to convert to uppercase',
'Transaction notes/memo text. Use for string operations.\n\nExample: =UPPER(notes) to convert to uppercase',
),
},
{
@@ -323,7 +304,7 @@ const transactionFields: Completion[] = [
section: '💰 Transaction Fields',
boost: 5,
info: t(
'Original imported payee name from bank import. Contains the raw text before matching.\n\nExample: `=LEFT(imported_payee, 10)` to get first 10 characters',
'Original imported payee name from bank import. Contains the raw text before matching.\n\nExample: =LEFT(imported_payee, 10) to get first 10 characters',
),
},
{
@@ -332,7 +313,7 @@ const transactionFields: Completion[] = [
section: '💰 Transaction Fields',
boost: 5,
info: t(
'Payee ID (string). The ID of the payee.\n\nExample: `=CONCATENATE("Payment to ", payee)`',
'Payee ID (string). The ID of the payee.\n\nExample: =CONCATENATE("Payment to ", payee)',
),
},
{
@@ -341,7 +322,7 @@ const transactionFields: Completion[] = [
section: '💰 Transaction Fields',
boost: 5,
info: t(
'Payee name (string). The human-readable name of the payee.\n\nExample: `=UPPER(payee_name)` or `=CONCATENATE("Payment to ", payee_name)`',
'Payee name (string). The human-readable name of the payee.\n\nExample: =UPPER(payee_name) or =CONCATENATE("Payment to ", payee_name)',
),
},
{
@@ -350,7 +331,7 @@ const transactionFields: Completion[] = [
section: '💰 Transaction Fields',
boost: 5,
info: t(
'Account ID (string). The ID of the account.\n\nExample: `=CONCATENATE("Paid from ", account)`',
'Account ID (string). The ID of the account.\n\nExample: =CONCATENATE("Paid from ", account)',
),
},
{
@@ -359,7 +340,7 @@ const transactionFields: Completion[] = [
section: '💰 Transaction Fields',
boost: 5,
info: t(
'Account name (string). The human-readable name of the account.\n\nExample: `=CONCATENATE("Paid from ", account_name)`',
'Account name (string). The human-readable name of the account.\n\nExample: =CONCATENATE("Paid from ", account_name)',
),
},
{
@@ -368,7 +349,7 @@ const transactionFields: Completion[] = [
section: '💰 Transaction Fields',
boost: 5,
info: t(
'Category ID (string). The ID of the category.\n\nExample: `=IF(category="Groceries", "Food", "Other")`',
'Category ID (string). The ID of the category.\n\nExample: =IF(category="Groceries", "Food", "Other")',
),
},
{
@@ -377,7 +358,7 @@ const transactionFields: Completion[] = [
section: '💰 Transaction Fields',
boost: 5,
info: t(
'Category Name (string). The human-readable name of the category.\n\nExample: `=IF(category_name="Groceries", "Food", "Other")`',
'Category Name (string). The human-readable name of the category.\n\nExample: =IF(category_name="Groceries", "Food", "Other")',
),
},
{
@@ -386,7 +367,7 @@ const transactionFields: Completion[] = [
section: '💰 Transaction Fields',
boost: 5,
info: t(
'Boolean cleared status. TRUE if transaction is cleared, FALSE otherwise.\n\nExample: `=IF(cleared, "Cleared", "Pending")`',
'Boolean cleared status. TRUE if transaction is cleared, FALSE otherwise.\n\nExample: =IF(cleared, "Cleared", "Pending")',
),
},
{
@@ -404,7 +385,7 @@ const transactionFields: Completion[] = [
section: '💰 Transaction Fields',
boost: 5,
info: t(
'Account balance as of the date of the transaction, excluding the transaction amount. Use for calculations and comparisons.\n\nExample: `=IF(balance < 0, "Negative Balance", "Positive Balance")`',
'Account balance as of the date of the transaction, excluding the transaction amount. Use for calculations and comparisons.\n\nExample: =IF(balance < 0, "Negative Balance", "Positive Balance")',
),
},
{
@@ -413,7 +394,7 @@ const transactionFields: Completion[] = [
section: '💰 Transaction Fields',
boost: 5,
info: t(
'The amount of the parent transaction in cents in split transactions.\n\nExample: `=(parent_amount / 100) * .05`',
'The amount of the parent transaction in cents in split transactions.\n\nExample: =(parent_amount / 100) * .05',
),
},
];

View File

@@ -23,70 +23,24 @@ export const queryModeFunctions: Record<string, FunctionDef> = {
{ name: 'numbers', description: 'Number1, Number2, ...NumberN' },
],
},
AVERAGEA: {
name: 'AVERAGEA',
description: t('Returns the average, including text and logical values.'),
parameters: [{ name: 'values', description: 'Value1, Value2, ...ValueN' }],
},
COUNT: {
name: 'COUNT',
description: t('Counts the number of numeric values.'),
parameters: [{ name: 'values', description: 'Value1, Value2, ...ValueN' }],
},
COUNTA: {
name: 'COUNTA',
description: t('Counts non-empty values.'),
parameters: [{ name: 'values', description: 'Value1, Value2, ...ValueN' }],
},
COUNTBLANK: {
name: 'COUNTBLANK',
description: t('Counts empty cells.'),
parameters: [{ name: 'range', description: 'Range' }],
},
COUNTIF: {
name: 'COUNTIF',
description: t('Counts cells that meet a criteria.'),
parameters: [
{ name: 'range', description: 'Range' },
{ name: 'criteria', description: 'Criteria' },
],
},
COUNTIFS: {
name: 'COUNTIFS',
description: t('Counts cells that meet multiple criteria.'),
parameters: [
{ name: 'range1', description: 'Range1' },
{ name: 'criteria1', description: 'Criteria1' },
],
},
// Note: AVERAGEA, COUNT, COUNTA, COUNTBLANK, COUNTIF, COUNTIFS removed
// Reason: These functions require cell ranges which don't exist in Actual's named expression context.
// Use QUERY_COUNT() for counting query results instead.
MAX: {
name: 'MAX',
description: t('Returns the maximum value.'),
description: t('Returns the maximum value from numbers.'),
parameters: [
{ name: 'numbers', description: 'Number1, Number2, ...NumberN' },
],
},
MAXA: {
name: 'MAXA',
description: t(
'Returns the maximum value, including text and logical values.',
),
parameters: [{ name: 'values', description: 'Value1, Value2, ...ValueN' }],
},
MIN: {
name: 'MIN',
description: t('Returns the minimum value.'),
description: t('Returns the minimum value from numbers.'),
parameters: [
{ name: 'numbers', description: 'Number1, Number2, ...NumberN' },
],
},
MINA: {
name: 'MINA',
description: t(
'Returns the minimum value, including text and logical values.',
),
parameters: [{ name: 'values', description: 'Value1, Value2, ...ValueN' }],
},
ABS: {
name: 'ABS',
description: t('Returns the absolute value of a number.'),
@@ -251,104 +205,12 @@ export const queryModeFunctions: Record<string, FunctionDef> = {
{ name: 'numbers', description: 'Number1, Number2, ...NumberN' },
],
},
SUMIF: {
name: 'SUMIF',
description: t('Sums cells that meet a criteria.'),
parameters: [
{ name: 'range', description: 'Range' },
{ name: 'criteria', description: 'Criteria' },
{ name: 'sum_range', description: 'SumRange' },
],
},
SUMIFS: {
name: 'SUMIFS',
description: t('Sums cells that meet multiple criteria.'),
parameters: [
{ name: 'sum_range', description: 'SumRange' },
{ name: 'range1', description: 'Range1' },
{ name: 'criteria1', description: 'Criteria1' },
],
},
SUMPRODUCT: {
name: 'SUMPRODUCT',
description: t('Multiplies corresponding elements and returns the sum.'),
parameters: [
{ name: 'array1', description: 'Array1' },
{ name: 'array2', description: 'Array2' },
],
},
SUMSQ: {
name: 'SUMSQ',
description: t('Returns the sum of the squares.'),
parameters: [
{ name: 'numbers', description: 'Number1, Number2, ...NumberN' },
],
},
// Note: SUMIF, SUMIFS, SUMPRODUCT, SUMSQ removed
// Reason: These functions require cell ranges. Use QUERY() for conditional sums instead.
// Statistical Functions
MEDIAN: {
name: 'MEDIAN',
description: t('Returns the median value.'),
parameters: [
{ name: 'numbers', description: 'Number1, Number2, ...NumberN' },
],
},
MODE: {
name: 'MODE',
description: t('Returns the most frequently occurring value.'),
parameters: [
{ name: 'numbers', description: 'Number1, Number2, ...NumberN' },
],
},
STDEV: {
name: 'STDEV',
description: t('Returns the standard deviation of a sample.'),
parameters: [
{ name: 'numbers', description: 'Number1, Number2, ...NumberN' },
],
},
STDEVP: {
name: 'STDEVP',
description: t('Returns the standard deviation of a population.'),
parameters: [
{ name: 'numbers', description: 'Number1, Number2, ...NumberN' },
],
},
VAR: {
name: 'VAR',
description: t('Returns the variance of a sample.'),
parameters: [{ name: 'values', description: 'Value1, Value2, ...ValueN' }],
},
VARP: {
name: 'VARP',
description: t('Returns the variance of a population.'),
parameters: [{ name: 'values', description: 'Value1, Value2, ...ValueN' }],
},
PERCENTILE: {
name: 'PERCENTILE',
description: t('Returns the k-th percentile.'),
parameters: [
{ name: 'array', description: 'Array' },
{ name: 'k', description: 'K' },
],
},
QUARTILE: {
name: 'QUARTILE',
description: t('Returns the quartile of a dataset.'),
parameters: [
{ name: 'array', description: 'Array' },
{ name: 'quart', description: 'Quart' },
],
},
RANK: {
name: 'RANK',
description: t('Returns the rank of a number in a list.'),
parameters: [
{ name: 'value', description: 'Value' },
{ name: 'array', description: 'Array' },
{ name: 'order', description: 'Order' },
],
},
// Note: Statistical functions (MEDIAN, MODE, STDEV, STDEVP, VAR, VARP, PERCENTILE, QUARTILE, RANK) removed
// Reason: These functions require arrays/ranges of data which don't exist in Actual's context.
// Users work with single query results, not ranges of cells.
// Logical Functions
IF: {
@@ -567,6 +429,52 @@ export const queryModeFunctions: Record<string, FunctionDef> = {
{ name: 'decimals', description: 'Decimals' },
],
},
FORMATNUMBER: {
name: 'FORMATNUMBER',
description: t(
'Formats a number with thousands separators. Uses your app number format settings by default.',
),
parameters: [
{ name: 'value', description: 'Number to format' },
{
name: 'decimals',
description: 'Decimal places (optional, uses app settings)',
},
{
name: 'thousandsSeparator',
description: 'Thousands separator (optional, uses app settings)',
},
{
name: 'decimalSeparator',
description: 'Decimal separator (optional, uses app settings)',
},
],
},
FORMATCURRENCY: {
name: 'FORMATCURRENCY',
description: t(
'Formats a number as currency. Uses your app currency and number format settings by default.',
),
parameters: [
{ name: 'value', description: 'Number to format' },
{
name: 'currencySymbol',
description: 'Currency symbol (optional, uses app settings)',
},
{
name: 'decimals',
description: 'Decimal places (optional, uses app settings)',
},
{
name: 'thousandsSeparator',
description: 'Thousands separator (optional, uses app settings)',
},
{
name: 'decimalSeparator',
description: 'Decimal separator (optional, uses app settings)',
},
],
},
REPT: {
name: 'REPT',
description: t('Repeats text specified number of times.'),
@@ -687,60 +595,17 @@ export const queryModeFunctions: Record<string, FunctionDef> = {
],
},
// Lookup and Reference
VLOOKUP: {
name: 'VLOOKUP',
description: t('Searches vertically in first column and returns value.'),
parameters: [
{ name: 'lookup_value', description: 'LookupValue' },
{ name: 'table_array', description: 'TableArray' },
{ name: 'col_index', description: 'ColIndex' },
{ name: 'range_lookup', description: 'RangeLookup' },
],
},
HLOOKUP: {
name: 'HLOOKUP',
description: t('Searches horizontally in first row and returns value.'),
parameters: [
{ name: 'lookup_value', description: 'LookupValue' },
{ name: 'table_array', description: 'TableArray' },
{ name: 'row_index', description: 'RowIndex' },
{ name: 'range_lookup', description: 'RangeLookup' },
],
},
INDEX: {
name: 'INDEX',
description: t('Returns value at specified row and column.'),
parameters: [
{ name: 'array', description: 'Array' },
{ name: 'row', description: 'Row' },
{ name: 'column', description: 'Column' },
],
},
MATCH: {
name: 'MATCH',
description: t('Returns position of value in array.'),
parameters: [
{ name: 'lookup_value', description: 'LookupValue' },
{ name: 'lookup_array', description: 'LookupArray' },
{ name: 'match_type', description: 'MatchType' },
],
},
// Note: Lookup and Reference functions (VLOOKUP, HLOOKUP, INDEX, MATCH, LOOKUP) removed
// Reason: These functions require table arrays and cell ranges which don't exist in Actual.
// Actual uses named queries and expressions, not spreadsheet-style cell references.
CHOOSE: {
name: 'CHOOSE',
description: t('Returns value from list based on index.'),
parameters: [
{ name: 'index', description: 'Index' },
{ name: 'index', description: 'Index (1-based)' },
{ name: 'value1', description: 'Value1' },
{ name: 'value2', description: 'Value2' },
],
},
LOOKUP: {
name: 'LOOKUP',
description: t('Looks up values in a vector or array.'),
parameters: [
{ name: 'lookup_value', description: 'LookupValue' },
{ name: 'lookup_vector', description: 'LookupVector' },
{ name: 'value2', description: 'Value2, ...ValueN' },
],
},

View File

@@ -142,6 +142,52 @@ export const transactionModeFunctions: Record<string, FunctionDef> = {
{ name: 'decimals', description: 'Decimals' },
],
},
FORMATNUMBER: {
name: 'FORMATNUMBER',
description: t(
'Formats a number with thousands separators. Uses your app number format settings by default.',
),
parameters: [
{ name: 'value', description: 'Number to format' },
{
name: 'decimals',
description: 'Decimal places (optional, uses app settings)',
},
{
name: 'thousandsSeparator',
description: 'Thousands separator (optional, uses app settings)',
},
{
name: 'decimalSeparator',
description: 'Decimal separator (optional, uses app settings)',
},
],
},
FORMATCURRENCY: {
name: 'FORMATCURRENCY',
description: t(
'Formats a number as currency. Uses your app currency and number format settings by default.',
),
parameters: [
{ name: 'value', description: 'Number to format' },
{
name: 'currencySymbol',
description: 'Currency symbol (optional, uses app settings)',
},
{
name: 'decimals',
description: 'Decimal places (optional, uses app settings)',
},
{
name: 'thousandsSeparator',
description: 'Thousands separator (optional, uses app settings)',
},
{
name: 'decimalSeparator',
description: 'Decimal separator (optional, uses app settings)',
},
],
},
REPT: {
name: 'REPT',
description: t('Repeats text specified number of times.'),
@@ -496,11 +542,7 @@ export const transactionModeFunctions: Record<string, FunctionDef> = {
description: t('Returns TRUE if value is logical (TRUE/FALSE).'),
parameters: [{ name: 'value', description: 'Value' }],
},
ISREF: {
name: 'ISREF',
description: t('Returns TRUE if value is a reference.'),
parameters: [{ name: 'value', description: 'Value' }],
},
// Note: ISREF removed - it checks for cell references which don't exist in Actual
ISEVEN: {
name: 'ISEVEN',
description: t('Returns TRUE if number is even.'),

View File

@@ -81,12 +81,16 @@ export function FormulaResult({
if (width <= 0 || height <= 0) return;
// Get the actual display value length at calculation time
const valueLength = displayValue.length || 1; // Avoid division by zero
// Feedback: Users requested support for line breaks (CHAR(10) or CHAR(13) in formulas).
// Check if the display value contains line breaks and calculate font size accordingly.
const lines = displayValue.split(/\r?\n/);
const lineCount = lines.length;
const longestLineLength = Math.max(...lines.map(line => line.length), 1);
// Calculate font size based on the longest line and number of lines
const calculatedFontSize = Math.min(
(width * FONT_SIZE_SCALE_FACTOR) / valueLength,
height, // Ensure the text fits vertically by using the height as the limiting factor
(width * FONT_SIZE_SCALE_FACTOR) / longestLineLength,
height / lineCount, // Divide height by number of lines to fit all lines
);
if (calculatedFontSize > 0) {
@@ -184,7 +188,14 @@ export function FormulaResult({
{!showContent ? (
<LoadingIndicator />
) : (
<span aria-hidden="true">
<span
aria-hidden="true"
style={{
whiteSpace: 'pre-wrap',
textAlign: 'center',
wordBreak: 'break-word',
}}
>
<PrivacyFilter>{displayValue}</PrivacyFilter>
</span>
)}

View File

@@ -560,15 +560,19 @@ async function fetchQueryCount(config: QueryConfig): Promise<number> {
}
// Helper: Extract category-based conditions (ignore transaction-specific filters)
// BUG FIX: Support both 'category' and 'category_group' fields
function extractCategoryConditions(
conditions: RuleConditionEntity[],
): RuleConditionEntity[] {
return conditions.filter(
cond => !cond.customName && cond.field === 'category',
cond =>
!cond.customName &&
(cond.field === 'category' || cond.field === 'category_group'),
);
}
// Helper: Evaluate category conditions to get matching categories
// BUG FIX: Support both 'category' and 'category_group' fields by expanding groups to their member categories
async function getCategoriesFromConditions(
allCategories: CategoryEntity[],
conditions: RuleConditionEntity[],
@@ -581,32 +585,55 @@ async function getCategoriesFromConditions(
.map((cat: CategoryEntity) => cat.id);
}
// Get category groups for resolving group IDs to names
const { grouped: categoryGroups } = await send('get-categories');
const groupNameById = new Map(
categoryGroups.map((g: { id: string; name: string }) => [g.id, g.name]),
);
// Evaluate each condition to get sets of matching categories
const conditionResults = conditions.map(cond => {
// For category_group conditions, we check cat.group; for category, we check cat.id
const getKey = (cat: CategoryEntity) =>
cond.field === 'category_group' ? cat.group : cat.id;
const matching = allCategories.filter((cat: CategoryEntity) => {
const key = getKey(cat);
// For text-based operators, use the human-readable name
// For category_group, resolve UUID → name via the map; for category, use the category's own name
const textValue =
cond.field === 'category_group'
? (groupNameById.get(key) ?? key)
: cat.name;
if (cond.op === 'is') {
return cond.value === cat.id;
return cond.value === key;
} else if (cond.op === 'isNot') {
return cond.value !== cat.id;
return cond.value !== key;
} else if (cond.op === 'oneOf') {
return cond.value.includes(cat.id);
return cond.value.includes(key);
} else if (cond.op === 'notOneOf') {
return !cond.value.includes(cat.id);
return !cond.value.includes(key);
} else if (cond.op === 'contains') {
return cat.name.includes(cond.value as string);
return textValue
.toLowerCase()
.includes((cond.value as string).toLowerCase());
} else if (cond.op === 'doesNotContain') {
return !cat.name.includes(cond.value as string);
return !textValue
.toLowerCase()
.includes((cond.value as string).toLowerCase());
} else if (cond.op === 'matches') {
try {
return new RegExp(cond.value as string).test(cat.name);
return new RegExp(cond.value as string, 'i').test(textValue);
} catch (e) {
console.warn('Invalid regexp in matches condition', e);
return true;
return false;
}
}
// Unknown operator: include category by default and log warning
// Unknown operator: exclude category by default and log warning
console.warn(`Unknown category condition operator: ${cond.op}`);
return true;
return false;
});
return matching.map((cat: CategoryEntity) => cat.id);
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 531 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -0,0 +1,739 @@
import { HyperFormula } from 'hyperformula';
import enUS from 'hyperformula/i18n/languages/enUS';
import { beforeEach, describe, expect, it } from 'vitest';
import { aqlQuery } from '#server/aql';
import * as db from '#server/db';
import { loadMappings } from '#server/db/mappings';
import {
CustomFunctionsPlugin,
customFunctionsTranslations,
} from '#server/rules/customFunctions';
import { conditionsToAQL } from '#server/transactions/transaction-rules';
import { q } from '#shared/query';
// Integration tests for formula cards with real database queries
// These tests validate formulas with actual query results from the database
// Register HyperFormula language and plugins if not already registered
try {
HyperFormula.registerLanguage('enUS', enUS);
} catch {
// Already registered, ignore
}
try {
HyperFormula.registerFunctionPlugin(
CustomFunctionsPlugin,
customFunctionsTranslations,
);
} catch {
// Already registered, ignore
}
beforeEach(async () => {
// oxlint-disable-next-line typescript/no-explicit-any
await (global as any).emptyDatabase()();
await loadMappings();
});
describe('Formula Card - Integration Tests with Queries', () => {
// Helper functions using db helper methods
async function createTestAccount(name: string) {
return await db.insertAccount({ name });
}
async function createCategoryGroup(name: string) {
return await db.insertCategoryGroup({ name });
}
async function createTestCategory(
name: string,
groupId: string,
isIncome = false,
) {
return await db.insertCategory({
name,
cat_group: groupId,
is_income: isIncome ? 1 : 0,
});
}
async function createTestTransaction(data: {
accountId: string;
amount: number;
date: string;
categoryId?: string;
notes?: string;
}) {
return await db.insertTransaction({
account: data.accountId,
amount: data.amount,
date: data.date,
category: data.categoryId || null,
notes: data.notes || null,
});
}
async function executeQuery(
conditions: unknown[],
timeFrame?: { start?: string; end?: string },
) {
// Simulate query execution like the formula card does
const { filters } = conditionsToAQL(conditions);
let transQuery = q('transactions');
if (timeFrame?.start && timeFrame?.end) {
transQuery = transQuery.filter({
$and: [
{ date: { $gte: timeFrame.start } },
{ date: { $lte: timeFrame.end } },
],
});
}
if (filters.length > 0) {
transQuery = transQuery.filter({ $and: filters });
}
const summedQuery = transQuery.calculate({ $sum: '$amount' });
const { data } = await aqlQuery(summedQuery);
return data || 0;
}
async function executeFormulaWithQuery(
formula: string,
queryResults: Record<string, number>,
) {
let hfInstance: ReturnType<typeof HyperFormula.buildEmpty> | null = null;
try {
hfInstance = HyperFormula.buildEmpty({
licenseKey: 'gpl-v3',
language: 'enUS',
dateFormats: ['DD/MM/YYYY', 'YYYY-MM-DD', 'YYYY/MM/DD'],
});
const sheetName = hfInstance.addSheet('Sheet1');
const sheetId = hfInstance.getSheetId(sheetName);
if (sheetId === undefined) {
throw new Error('Failed to create sheet');
}
// Replace QUERY() calls with actual values
let processedFormula = formula;
for (const [queryName, value] of Object.entries(queryResults)) {
const regex = new RegExp(
`QUERY\\s*\\(\\s*["']${queryName}["']\\s*\\)`,
'gi',
);
// Convert cents to dollars for display
const dollarValue = value / 100;
processedFormula = processedFormula.replace(regex, String(dollarValue));
}
hfInstance.setCellContents({ sheet: sheetId, col: 0, row: 0 }, [
[processedFormula],
]);
const cellValue = hfInstance.getCellValue({
sheet: sheetId,
col: 0,
row: 0,
});
if (cellValue && typeof cellValue === 'object' && 'type' in cellValue) {
throw new Error(`Formula error: ${cellValue.type}`);
}
return cellValue;
} finally {
hfInstance?.destroy();
}
}
describe('Basic Query Integration', () => {
it('should execute formula with single query result', async () => {
// Integration test: Simple query sum with formula
const accountId = await createTestAccount('Checking');
const groupId = await createCategoryGroup('Expenses');
const categoryId = await createTestCategory('Groceries', groupId);
// Create test transactions
await createTestTransaction({
accountId,
amount: -5000,
date: '2024-01-15',
categoryId,
});
await createTestTransaction({
accountId,
amount: -7500,
date: '2024-01-20',
categoryId,
});
await createTestTransaction({
accountId,
amount: -3000,
date: '2024-01-25',
categoryId,
});
// Execute query
const queryResult = await executeQuery([
{ field: 'category', op: 'is', value: categoryId, type: 'id' },
]);
// Execute formula with query result
const formula = '=FORMATCURRENCY(QUERY("Groceries"))';
const result = await executeFormulaWithQuery(formula, {
Groceries: queryResult,
});
// FORMATCURRENCY places negative sign before currency symbol
expect(result).toBe('-$155.00');
});
it('should calculate percentage from query results', async () => {
// Integration test: Calculate spending as percentage of income
const accountId = await createTestAccount('Checking');
const groupId = await createCategoryGroup('Income');
const incomeCategory = await createTestCategory('Salary', groupId, true);
const expenseGroup = await createCategoryGroup('Expenses');
const expenseCategory = await createTestCategory('Food', expenseGroup);
// Create income
await createTestTransaction({
accountId,
amount: 500000, // $5000
date: '2024-01-01',
categoryId: incomeCategory,
});
// Create expenses
await createTestTransaction({
accountId,
amount: -75000, // $750
date: '2024-01-15',
categoryId: expenseCategory,
});
const incomeResult = await executeQuery([
{ field: 'category', op: 'is', value: incomeCategory, type: 'id' },
]);
const expenseResult = await executeQuery([
{ field: 'category', op: 'is', value: expenseCategory, type: 'id' },
]);
const formula =
'=CONCATENATE(FORMATNUMBER((ABS(QUERY("Expenses")) / QUERY("Income")) * 100, 1), "%")';
const result = await executeFormulaWithQuery(formula, {
Income: incomeResult,
Expenses: expenseResult,
});
expect(result).toBe('15.0%');
});
it('should format large query results with thousands separators', async () => {
// Integration test: Format large numbers from queries
const accountId = await createTestAccount('Investment');
const groupId = await createCategoryGroup('Income');
const categoryId = await createTestCategory('Dividends', groupId, true);
// Create large transactions
await createTestTransaction({
accountId,
amount: 123456789, // $1,234,567.89
date: '2024-01-01',
categoryId,
});
const queryResult = await executeQuery([
{ field: 'category', op: 'is', value: categoryId, type: 'id' },
]);
const formula = '=FORMATNUMBER(QUERY("Dividends"), 2)';
const result = await executeFormulaWithQuery(formula, {
Dividends: queryResult,
});
expect(result).toBe('1,234,567.89');
});
});
describe('Multiple Query Integration', () => {
it('should combine multiple query results in formula', async () => {
// Integration test: Calculate net worth from multiple queries
const checkingId = await createTestAccount('Checking');
const savingsId = await createTestAccount('Savings');
const creditCardId = await createTestAccount('Credit Card');
// Create transactions
await createTestTransaction({
accountId: checkingId,
amount: 250000, // $2500
date: '2024-01-01',
});
await createTestTransaction({
accountId: savingsId,
amount: 1000000, // $10000
date: '2024-01-01',
});
await createTestTransaction({
accountId: creditCardId,
amount: -50000, // -$500
date: '2024-01-01',
});
const checkingResult = await executeQuery([
{ field: 'account', op: 'is', value: checkingId, type: 'id' },
]);
const savingsResult = await executeQuery([
{ field: 'account', op: 'is', value: savingsId, type: 'id' },
]);
const creditCardResult = await executeQuery([
{ field: 'account', op: 'is', value: creditCardId, type: 'id' },
]);
const formula =
'=FORMATCURRENCY(QUERY("Checking") + QUERY("Savings") + QUERY("CreditCard"))';
const result = await executeFormulaWithQuery(formula, {
Checking: checkingResult,
Savings: savingsResult,
CreditCard: creditCardResult,
});
// Query results: 250000 + 1000000 - 50000 = 1200000 cents = 12000 dollars
expect(result).toBe('$12,000.00');
});
it('should calculate ratios between multiple queries', async () => {
// Integration test: Calculate savings rate
const accountId = await createTestAccount('Checking');
const incomeGroup = await createCategoryGroup('Income');
const incomeCategory = await createTestCategory(
'Salary',
incomeGroup,
true,
);
const savingsGroup = await createCategoryGroup('Savings');
const savingsCategory = await createTestCategory('Savings', savingsGroup);
await createTestTransaction({
accountId,
amount: 600000, // $6000 income
date: '2024-01-01',
categoryId: incomeCategory,
});
await createTestTransaction({
accountId,
amount: -120000, // $1200 savings
date: '2024-01-15',
categoryId: savingsCategory,
});
const incomeResult = await executeQuery([
{ field: 'category', op: 'is', value: incomeCategory, type: 'id' },
]);
const savingsResult = await executeQuery([
{ field: 'category', op: 'is', value: savingsCategory, type: 'id' },
]);
const formula =
'=CONCATENATE("Savings Rate: ", FORMATNUMBER((ABS(QUERY("Savings")) / QUERY("Income")) * 100, 1), "%")';
const result = await executeFormulaWithQuery(formula, {
Income: incomeResult,
Savings: savingsResult,
});
expect(result).toBe('Savings Rate: 20.0%');
});
});
describe('Complex Nested Formulas with Queries', () => {
it('should handle deeply nested calculations with multiple queries', async () => {
// Integration test: Complex budget analysis
const accountId = await createTestAccount('Checking');
const incomeGroup = await createCategoryGroup('Income');
const incomeCategory = await createTestCategory(
'Salary',
incomeGroup,
true,
);
const needsGroup = await createCategoryGroup('Needs');
const needsCategory = await createTestCategory('Housing', needsGroup);
const wantsGroup = await createCategoryGroup('Wants');
const wantsCategory = await createTestCategory(
'Entertainment',
wantsGroup,
);
await createTestTransaction({
accountId,
amount: 500000, // $5000 income
date: '2024-01-01',
categoryId: incomeCategory,
});
await createTestTransaction({
accountId,
amount: -200000, // $2000 needs
date: '2024-01-10',
categoryId: needsCategory,
});
await createTestTransaction({
accountId,
amount: -100000, // $1000 wants
date: '2024-01-15',
categoryId: wantsCategory,
});
const incomeResult = await executeQuery([
{ field: 'category', op: 'is', value: incomeCategory, type: 'id' },
]);
const needsResult = await executeQuery([
{ field: 'category', op: 'is', value: needsCategory, type: 'id' },
]);
const wantsResult = await executeQuery([
{ field: 'category', op: 'is', value: wantsCategory, type: 'id' },
]);
const formula =
'=CONCATENATE("Income: ", FORMATCURRENCY(QUERY("Income")), " | Needs: ", FORMATNUMBER((ABS(QUERY("Needs")) / QUERY("Income")) * 100, 0), "% | Wants: ", FORMATNUMBER((ABS(QUERY("Wants")) / QUERY("Income")) * 100, 0), "%")';
const result = await executeFormulaWithQuery(formula, {
Income: incomeResult,
Needs: needsResult,
Wants: wantsResult,
});
// Income: 500000 cents = 5000 dollars
expect(result).toBe('Income: $5,000.00 | Needs: 40% | Wants: 20%');
});
it('should use conditional logic with query results', async () => {
// Integration test: Budget status with IF statements
const accountId = await createTestAccount('Checking');
const groupId = await createCategoryGroup('Expenses');
const categoryId = await createTestCategory('Dining', groupId);
await createTestTransaction({
accountId,
amount: -35000, // $350
date: '2024-01-15',
categoryId,
});
const queryResult = await executeQuery([
{ field: 'category', op: 'is', value: categoryId, type: 'id' },
]);
const formula =
'=IF(ABS(QUERY("Dining")) > 400, "Over Budget", IF(ABS(QUERY("Dining")) > 300, "Near Limit", "On Track"))';
const result = await executeFormulaWithQuery(formula, {
Dining: queryResult,
});
expect(result).toBe('Near Limit');
});
it('should create multi-line output with query results', async () => {
// Integration test: Multi-line summary with CHAR(10)
const accountId = await createTestAccount('Checking');
const incomeGroup = await createCategoryGroup('Income');
const incomeCategory = await createTestCategory(
'Salary',
incomeGroup,
true,
);
const expenseGroup = await createCategoryGroup('Expenses');
const expenseCategory = await createTestCategory('Total', expenseGroup);
await createTestTransaction({
accountId,
amount: 400000, // $4000
date: '2024-01-01',
categoryId: incomeCategory,
});
await createTestTransaction({
accountId,
amount: -150000, // $1500
date: '2024-01-15',
categoryId: expenseCategory,
});
const incomeResult = await executeQuery([
{ field: 'category', op: 'is', value: incomeCategory, type: 'id' },
]);
const expenseResult = await executeQuery([
{ field: 'category', op: 'is', value: expenseCategory, type: 'id' },
]);
const formula =
'=CONCATENATE("Income: ", FORMATCURRENCY(QUERY("Income")), CHAR(10), "Expenses: ", FORMATCURRENCY(QUERY("Expenses")), CHAR(10), "Net: ", FORMATCURRENCY(QUERY("Income") + QUERY("Expenses")))';
const result = await executeFormulaWithQuery(formula, {
Income: incomeResult,
Expenses: expenseResult,
});
// Income: 400000 cents = 4000 dollars, Expenses: -150000 cents = -1500 dollars
expect(result).toContain('Income: $4,000.00');
expect(result).toContain('\n');
expect(result).toContain('Expenses: -$1,500.00');
expect(result).toContain('Net: $2,500.00');
});
});
describe('Date-based Query Integration', () => {
it('should filter queries by date range', async () => {
// Integration test: Query with time frame
const accountId = await createTestAccount('Checking');
const groupId = await createCategoryGroup('Expenses');
const categoryId = await createTestCategory('Shopping', groupId);
// Transactions in different months
await createTestTransaction({
accountId,
amount: -10000,
date: '2024-01-15',
categoryId,
});
await createTestTransaction({
accountId,
amount: -15000,
date: '2024-02-15',
categoryId,
});
await createTestTransaction({
accountId,
amount: -20000,
date: '2024-03-15',
categoryId,
});
// Query only January
const queryResult = await executeQuery(
[{ field: 'category', op: 'is', value: categoryId, type: 'id' }],
{ start: '2024-01-01', end: '2024-01-31' },
);
const formula = '=FORMATCURRENCY(QUERY("Shopping"))';
const result = await executeFormulaWithQuery(formula, {
Shopping: queryResult,
});
// Query result: -10000 cents = -100 dollars
expect(result).toBe('-$100.00');
});
it('should compare different time periods', async () => {
// Integration test: Month-over-month comparison
const accountId = await createTestAccount('Checking');
const groupId = await createCategoryGroup('Expenses');
const categoryId = await createTestCategory('Utilities', groupId);
await createTestTransaction({
accountId,
amount: -12000,
date: '2024-01-15',
categoryId,
});
await createTestTransaction({
accountId,
amount: -15000,
date: '2024-02-15',
categoryId,
});
const jan = await executeQuery(
[{ field: 'category', op: 'is', value: categoryId, type: 'id' }],
{ start: '2024-01-01', end: '2024-01-31' },
);
const feb = await executeQuery(
[{ field: 'category', op: 'is', value: categoryId, type: 'id' }],
{ start: '2024-02-01', end: '2024-02-29' },
);
const formula =
'=CONCATENATE("Change: ", FORMATNUMBER(((ABS(QUERY("Feb")) - ABS(QUERY("Jan"))) / ABS(QUERY("Jan"))) * 100, 1), "%")';
const result = await executeFormulaWithQuery(formula, {
Jan: jan,
Feb: feb,
});
expect(result).toBe('Change: 25.0%');
});
});
describe('Error Handling with Queries', () => {
it('should handle empty query results', async () => {
// Integration test: Query with no matching transactions
const groupId = await createCategoryGroup('Expenses');
const categoryId = await createTestCategory('Empty', groupId);
// No transactions created for this category
const queryResult = await executeQuery([
{ field: 'category', op: 'is', value: categoryId, type: 'id' },
]);
const formula = '=FORMATCURRENCY(QUERY("Empty"))';
const result = await executeFormulaWithQuery(formula, {
Empty: queryResult,
});
expect(result).toBe('$0.00');
});
it('should handle division by zero with IFERROR', async () => {
// Integration test: Safe division with empty query
const accountId = await createTestAccount('Checking');
const groupId = await createCategoryGroup('Income');
const incomeCategory = await createTestCategory('Salary', groupId, true);
const expenseGroup = await createCategoryGroup('Expenses');
const expenseCategory = await createTestCategory('Food', expenseGroup);
// Only create expense, no income
await createTestTransaction({
accountId,
amount: -10000,
date: '2024-01-15',
categoryId: expenseCategory,
});
const incomeResult = await executeQuery([
{ field: 'category', op: 'is', value: incomeCategory, type: 'id' },
]);
const expenseResult = await executeQuery([
{ field: 'category', op: 'is', value: expenseCategory, type: 'id' },
]);
const formula =
'=IFERROR(CONCATENATE(FORMATNUMBER((ABS(QUERY("Expenses")) / QUERY("Income")) * 100, 0), "%"), "No Income")';
const result = await executeFormulaWithQuery(formula, {
Income: incomeResult,
Expenses: expenseResult,
});
expect(result).toBe('No Income');
});
});
describe('Advanced Query Scenarios', () => {
it('should calculate running totals across accounts', async () => {
// Integration test: Net worth calculation
const checking = await createTestAccount('Checking');
const savings = await createTestAccount('Savings');
const investment = await createTestAccount('Investment');
const creditCard = await createTestAccount('Credit Card');
await createTestTransaction({
accountId: checking,
amount: 150000,
date: '2024-01-01',
});
await createTestTransaction({
accountId: savings,
amount: 500000,
date: '2024-01-01',
});
await createTestTransaction({
accountId: investment,
amount: 1000000,
date: '2024-01-01',
});
await createTestTransaction({
accountId: creditCard,
amount: -25000,
date: '2024-01-01',
});
const checkingResult = await executeQuery([
{ field: 'account', op: 'is', value: checking, type: 'id' },
]);
const savingsResult = await executeQuery([
{ field: 'account', op: 'is', value: savings, type: 'id' },
]);
const investmentResult = await executeQuery([
{ field: 'account', op: 'is', value: investment, type: 'id' },
]);
const creditCardResult = await executeQuery([
{ field: 'account', op: 'is', value: creditCard, type: 'id' },
]);
const formula =
'=FORMATCURRENCY(QUERY("Checking") + QUERY("Savings") + QUERY("Investment") + QUERY("CreditCard"))';
const result = await executeFormulaWithQuery(formula, {
Checking: checkingResult,
Savings: savingsResult,
Investment: investmentResult,
CreditCard: creditCardResult,
});
expect(result).toBe('$16,250.00');
});
it('should use MAX and MIN with query results', async () => {
// Integration test: Find highest spending category
const accountId = await createTestAccount('Checking');
const groupId = await createCategoryGroup('Expenses');
const cat1 = await createTestCategory('Category1', groupId);
const cat2 = await createTestCategory('Category2', groupId);
const cat3 = await createTestCategory('Category3', groupId);
await createTestTransaction({
accountId,
amount: -15000,
date: '2024-01-15',
categoryId: cat1,
});
await createTestTransaction({
accountId,
amount: -25000,
date: '2024-01-15',
categoryId: cat2,
});
await createTestTransaction({
accountId,
amount: -10000,
date: '2024-01-15',
categoryId: cat3,
});
const result1 = await executeQuery([
{ field: 'category', op: 'is', value: cat1, type: 'id' },
]);
const result2 = await executeQuery([
{ field: 'category', op: 'is', value: cat2, type: 'id' },
]);
const result3 = await executeQuery([
{ field: 'category', op: 'is', value: cat3, type: 'id' },
]);
const formula =
'=CONCATENATE("Highest: ", FORMATCURRENCY(MIN(QUERY("Cat1"), QUERY("Cat2"), QUERY("Cat3"))))';
const result = await executeFormulaWithQuery(formula, {
Cat1: result1,
Cat2: result2,
Cat3: result3,
});
// MIN of -150, -250, -100 = -250 dollars
expect(result).toBe('Highest: -$250.00');
});
});
});

View File

@@ -13,6 +13,7 @@ import { amountToInteger } from '#shared/util';
import {
CustomFunctionsPlugin,
customFunctionsTranslations,
loadUserPreferencesForFormulas,
} from './customFunctions';
import { assert } from './rule-utils';
@@ -22,6 +23,10 @@ HyperFormula.registerFunctionPlugin(
customFunctionsTranslations,
);
// Load user preferences on module initialization for formatting functions
// This is async but happens once when the module loads
void loadUserPreferencesForFormulas();
const ACTION_OPS = [
'set',
'set-split-amount',

View File

@@ -2,7 +2,162 @@ import { FunctionArgumentType, FunctionPlugin } from 'hyperformula';
import type { InterpreterState } from 'hyperformula/typings/interpreter/InterpreterState';
import type { ProcedureAst } from 'hyperformula/typings/parser';
import { integerToAmount } from '#shared/util';
import * as db from '#server/db';
import { getCurrency } from '#shared/currencies';
import type { Currency } from '#shared/currencies';
import { getNumberFormat, integerToAmount } from '#shared/util';
import type { NumberFormats } from '#shared/util';
// User feedback: Make formatting functions respect app settings with locale-based fallbacks
// Global state to store user preferences for formatting functions
// This is set before formula execution to avoid async issues in HyperFormula custom functions
let cachedUserPreferences: {
currency: Currency;
numberFormat: NumberFormats;
thousandsSeparator: string;
decimalSeparator: string;
locale: string;
} | null = null;
// Helper to get locale-based number format defaults
function getLocaleDefaults(locale?: string): {
thousandsSeparator: string;
decimalSeparator: string;
} {
// Default to en-US if no locale
const actualLocale = locale || 'en-US';
// Map common locales to their number formats
if (
actualLocale.startsWith('de') ||
actualLocale.startsWith('es') ||
actualLocale.startsWith('it')
) {
// German, Spanish, Italian: 1.000,00
return { thousandsSeparator: '.', decimalSeparator: ',' };
} else if (
actualLocale.startsWith('fr') ||
actualLocale.startsWith('ru') ||
actualLocale.startsWith('cs')
) {
// French, Russian, Czech: 1 000,00
return { thousandsSeparator: '\u202F', decimalSeparator: ',' };
} else if (actualLocale.startsWith('de-CH')) {
// Swiss German: 1'000.00
return { thousandsSeparator: '\u2019', decimalSeparator: '.' };
} else if (
actualLocale.startsWith('en-IN') ||
actualLocale.startsWith('hi')
) {
// Indian: 1,00,000.00 (but we'll use standard comma for simplicity)
return { thousandsSeparator: ',', decimalSeparator: '.' };
} else {
// Default (en-US, en-GB, etc.): 1,000.00
return { thousandsSeparator: ',', decimalSeparator: '.' };
}
}
// Helper to determine currency from locale
function getCurrencyFromLocale(locale: string): Currency {
if (locale.startsWith('en-GB')) {
return getCurrency('GBP');
} else if (
locale.startsWith('de') ||
locale.startsWith('fr') ||
locale.startsWith('es') ||
locale.startsWith('it') ||
locale.startsWith('nl')
) {
return getCurrency('EUR');
} else if (locale.startsWith('ja')) {
return getCurrency('JPY');
} else if (locale.startsWith('en-IN') || locale.startsWith('hi')) {
return getCurrency('INR');
} else if (locale.startsWith('en-CA')) {
return getCurrency('CAD');
} else if (locale.startsWith('en-AU')) {
return getCurrency('AUD');
} else {
return getCurrency('USD');
}
}
// Function to load and cache user preferences
// This should be called before formula execution (can be async)
export async function loadUserPreferencesForFormulas(): Promise<void> {
try {
// Get currency code from preferences
const currencyCodePref = await db.first<Pick<db.DbPreference, 'value'>>(
'SELECT value FROM preferences WHERE id = ?',
['defaultCurrencyCode'],
);
const currencyCode = currencyCodePref?.value || null;
// Get number format from preferences
const numberFormatPref = await db.first<Pick<db.DbPreference, 'value'>>(
'SELECT value FROM preferences WHERE id = ?',
['numberFormat'],
);
const numberFormatValue =
(numberFormatPref?.value as NumberFormats) || null;
// Get locale from preferences
const localePref = await db.first<Pick<db.DbPreference, 'value'>>(
'SELECT value FROM preferences WHERE id = ?',
['locale'],
);
const locale = localePref?.value || 'en-US';
// Determine currency
const currency = currencyCode
? getCurrency(currencyCode)
: getCurrencyFromLocale(locale);
// Get number format settings
const numberFormatSettings = getNumberFormat({
format: numberFormatValue || undefined,
});
// Get locale-based defaults as fallback
const localeDefaults = getLocaleDefaults(locale);
cachedUserPreferences = {
currency,
numberFormat: numberFormatValue || 'comma-dot',
thousandsSeparator:
numberFormatSettings.thousandsSeparator ||
localeDefaults.thousandsSeparator,
decimalSeparator:
numberFormatSettings.decimalSeparator ||
localeDefaults.decimalSeparator,
locale,
};
} catch {
// Fallback to defaults if preferences can't be loaded
cachedUserPreferences = {
currency: getCurrency('USD'),
numberFormat: 'comma-dot',
thousandsSeparator: ',',
decimalSeparator: '.',
locale: 'en-US',
};
}
}
// Synchronous getter for cached preferences (used by custom functions)
function getUserPreferences() {
if (!cachedUserPreferences) {
// If not loaded, use defaults
return {
currency: getCurrency('USD'),
numberFormat: 'comma-dot' as NumberFormats,
thousandsSeparator: ',',
decimalSeparator: '.',
locale: 'en-US',
};
}
return cachedUserPreferences;
}
export class CustomFunctionsPlugin extends FunctionPlugin {
integerToAmount(ast: ProcedureAst, state: InterpreterState) {
@@ -40,6 +195,110 @@ export class CustomFunctionsPlugin extends FunctionPlugin {
},
);
}
// Feedback: Users reported that TEXT() function doesn't properly format numbers with
// thousands separators (e.g., TEXT(value, "$#,##0.00") doesn't work as expected).
// This custom function provides proper number formatting with thousands separators.
// User feedback: Should respect app's number format settings, with locale-based fallbacks
formatNumber(ast: ProcedureAst, state: InterpreterState) {
return this.runFunction(
ast.args,
state,
this.metadata('FORMATNUMBER'),
(
value: number,
decimals?: number,
thousandsSeparator?: string,
decimalSeparator?: string,
) => {
const num = Number(value);
if (isNaN(num)) {
return '#VALUE!';
}
// Get cached user preferences
const prefs = getUserPreferences();
// Priority: explicit parameter > app settings > locale defaults
const actualThousandsSeparator =
thousandsSeparator ?? prefs.thousandsSeparator;
const actualDecimalSeparator =
decimalSeparator ?? prefs.decimalSeparator;
const actualDecimals = decimals ?? 2;
const fixedNum = num.toFixed(actualDecimals);
const [integerPart, decimalPart] = fixedNum.split('.');
const formattedInteger = integerPart.replace(
/\B(?=(\d{3})+(?!\d))/g,
actualThousandsSeparator,
);
if (actualDecimals > 0 && decimalPart) {
return `${formattedInteger}${actualDecimalSeparator}${decimalPart}`;
}
return formattedInteger;
},
);
}
// Feedback: Users need proper currency formatting for formula cards.
// This function formats numbers as currency with symbol, thousands separators, and decimals.
// User feedback: Should respect app's currency and number format settings, with locale-based fallbacks
formatCurrency(ast: ProcedureAst, state: InterpreterState) {
return this.runFunction(
ast.args,
state,
this.metadata('FORMATCURRENCY'),
(
value: number,
currencySymbol?: string,
decimals?: number,
thousandsSeparator?: string,
decimalSeparator?: string,
) => {
const num = Number(value);
if (isNaN(num)) {
return '#VALUE!';
}
// Get cached user preferences
const prefs = getUserPreferences();
// Priority: explicit parameter > app settings > locale defaults
const actualCurrencySymbol = currencySymbol ?? prefs.currency.symbol;
const actualDecimals = decimals ?? prefs.currency.decimalPlaces;
const actualThousandsSeparator =
thousandsSeparator ?? prefs.thousandsSeparator;
const actualDecimalSeparator =
decimalSeparator ?? prefs.decimalSeparator;
const isNegative = num < 0;
const absNum = Math.abs(num);
const fixedNum = absNum.toFixed(actualDecimals);
const [integerPart, decimalPart] = fixedNum.split('.');
const formattedInteger = integerPart.replace(
/\B(?=(\d{3})+(?!\d))/g,
actualThousandsSeparator,
);
let result =
actualDecimals > 0 && decimalPart
? `${formattedInteger}${actualDecimalSeparator}${decimalPart}`
: formattedInteger;
result = `${actualCurrencySymbol}${result}`;
if (isNegative) {
result = `-${result}`;
}
return result;
},
);
}
}
CustomFunctionsPlugin.implementedFunctions = {
@@ -69,6 +328,53 @@ CustomFunctionsPlugin.implementedFunctions = {
},
],
},
FORMATNUMBER: {
method: 'formatNumber',
parameters: [
{ argumentType: FunctionArgumentType.NUMBER },
{
argumentType: FunctionArgumentType.NUMBER,
optionalArg: true,
defaultValue: 2,
},
{
argumentType: FunctionArgumentType.STRING,
optionalArg: true,
defaultValue: ',',
},
{
argumentType: FunctionArgumentType.STRING,
optionalArg: true,
defaultValue: '.',
},
],
},
FORMATCURRENCY: {
method: 'formatCurrency',
parameters: [
{ argumentType: FunctionArgumentType.NUMBER },
{
argumentType: FunctionArgumentType.STRING,
optionalArg: true,
defaultValue: '$',
},
{
argumentType: FunctionArgumentType.NUMBER,
optionalArg: true,
defaultValue: 2,
},
{
argumentType: FunctionArgumentType.STRING,
optionalArg: true,
defaultValue: ',',
},
{
argumentType: FunctionArgumentType.STRING,
optionalArg: true,
defaultValue: '.',
},
],
},
};
export const customFunctionsTranslations = {
@@ -76,5 +382,7 @@ export const customFunctionsTranslations = {
BALANCE_OF: 'BALANCE_OF',
FIXED: 'FIXED',
INTEGER_TO_AMOUNT: 'INTEGER_TO_AMOUNT',
FORMATNUMBER: 'FORMATNUMBER',
FORMATCURRENCY: 'FORMATCURRENCY',
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -210,4 +210,85 @@ describe('Formula-based rule actions', () => {
// Should convert number to string
expect(transaction.notes).toBe('75000');
});
// Feedback: Users reported TEXT() function doesn't properly format numbers with thousands separators.
// These tests verify the new FORMATNUMBER function works correctly.
it('should format numbers with thousands separators using FORMATNUMBER', () => {
const action = new Action('set', 'notes', null, {
formula: '=FORMATNUMBER(1234567.89, 2)',
});
const transaction = { notes: 'original' };
action.exec(transaction);
expect(transaction.notes).toBe('1,234,567.89');
});
it('should format numbers with custom separators using FORMATNUMBER', () => {
const action = new Action('set', 'notes', null, {
formula: '=FORMATNUMBER(1234567.89, 2, ".", ",")',
});
const transaction = { notes: 'original' };
action.exec(transaction);
expect(transaction.notes).toBe('1.234.567,89');
});
it('should format numbers without decimals using FORMATNUMBER', () => {
const action = new Action('set', 'notes', null, {
formula: '=FORMATNUMBER(1234567, 0)',
});
const transaction = { notes: 'original' };
action.exec(transaction);
expect(transaction.notes).toBe('1,234,567');
});
// Feedback: Users need proper currency formatting for formula cards.
// These tests verify the new FORMATCURRENCY function works correctly.
it('should format currency with default settings using FORMATCURRENCY', () => {
const action = new Action('set', 'notes', null, {
formula: '=FORMATCURRENCY(1234567.89)',
});
const transaction = { notes: 'original' };
action.exec(transaction);
expect(transaction.notes).toBe('$1,234,567.89');
});
it('should format currency with custom symbol using FORMATCURRENCY', () => {
const action = new Action('set', 'notes', null, {
formula: '=FORMATCURRENCY(1234567.89, "€")',
});
const transaction = { notes: 'original' };
action.exec(transaction);
expect(transaction.notes).toBe('€1,234,567.89');
});
it('should format negative currency correctly using FORMATCURRENCY', () => {
const action = new Action('set', 'notes', null, {
formula: '=FORMATCURRENCY(-1234567.89)',
});
const transaction = { notes: 'original' };
action.exec(transaction);
expect(transaction.notes).toBe('-$1,234,567.89');
});
it('should format currency with custom separators using FORMATCURRENCY', () => {
const action = new Action('set', 'notes', null, {
formula: '=FORMATCURRENCY(1234567.89, "€", 2, ".", ",")',
});
const transaction = { notes: 'original' };
action.exec(transaction);
expect(transaction.notes).toBe('€1.234.567,89');
});
});

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [lelemm]
---
Improve FORMATNUMBER and FORMATCURRENCY functions and fix tooltip display issues.