mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-06 07:01:45 -05:00
Compare commits
11 Commits
release/26
...
cursor/for
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc349efef5 | ||
|
|
d3dee95b6b | ||
|
|
fe0f903e75 | ||
|
|
0a26108f47 | ||
|
|
65f5a11cca | ||
|
|
837300c4be | ||
|
|
2bb86c4ffb | ||
|
|
00ee079696 | ||
|
|
efae23fd70 | ||
|
|
4b56f31590 | ||
|
|
517ac78f84 |
@@ -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',
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 |
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
6
upcoming-release-notes/7451.md
Normal file
6
upcoming-release-notes/7451.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [lelemm]
|
||||
---
|
||||
|
||||
Improve FORMATNUMBER and FORMATCURRENCY functions and fix tooltip display issues.
|
||||
Reference in New Issue
Block a user