@the-trybe/formula-engine 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -2
- package/package.json +1 -1
- package/src/evaluateAll-rounding.test.ts +608 -0
- package/src/formula-engine.ts +22 -4
- package/src/index.ts +1 -0
- package/src/types.ts +13 -0
package/README.md
CHANGED
|
@@ -88,6 +88,118 @@ const results = engine.evaluateAll(formulas, context);
|
|
|
88
88
|
console.log(results.results.get('total')?.value.toString()); // "540"
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
+
### Formula Definition Options
|
|
92
|
+
|
|
93
|
+
Each formula in `evaluateAll()` supports additional configuration options:
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
interface FormulaDefinition {
|
|
97
|
+
id: string; // Unique identifier for the formula
|
|
98
|
+
expression: string; // The expression to evaluate
|
|
99
|
+
dependencies?: string[]; // Explicit dependencies (auto-detected if omitted)
|
|
100
|
+
rounding?: RoundingConfig; // Rounding configuration for the result
|
|
101
|
+
onError?: ErrorBehavior; // How to handle evaluation errors
|
|
102
|
+
defaultValue?: unknown; // Default value when using onError: 'DEFAULT'
|
|
103
|
+
metadata?: Record<string, unknown>; // Custom metadata (not used by engine)
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
#### Default Intermediate Rounding
|
|
108
|
+
|
|
109
|
+
For financial calculations, configure `defaultRounding` in the engine to automatically round all intermediate values in `evaluateAll()`:
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
const engine = new FormulaEngine({
|
|
113
|
+
defaultRounding: { mode: 'HALF_UP', precision: 2 }
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const formulas = [
|
|
117
|
+
{ id: 'subtotal', expression: '$quantity * $unitPrice' },
|
|
118
|
+
{ id: 'tax', expression: '$subtotal * 0.0825' }, // Uses rounded subtotal
|
|
119
|
+
{ id: 'total', expression: '$subtotal + $tax' },
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
const results = engine.evaluateAll(formulas, {
|
|
123
|
+
variables: { quantity: 3, unitPrice: 10.33 }
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// subtotal = 30.99 (auto-rounded)
|
|
127
|
+
// tax = 2.56 (auto-rounded, calculated from rounded subtotal)
|
|
128
|
+
// total = 33.55
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
This ensures intermediate values are rounded before being used in dependent formulas, which is critical for financial/accounting calculations.
|
|
132
|
+
|
|
133
|
+
#### Disabling Intermediate Rounding
|
|
134
|
+
|
|
135
|
+
To disable the default intermediate rounding for specific batch evaluations, use the `disableIntermediateRounding` option:
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
const results = engine.evaluateAll(formulas, context, {
|
|
139
|
+
disableIntermediateRounding: true
|
|
140
|
+
});
|
|
141
|
+
// Raw unrounded values will propagate through dependencies
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
#### Per-Formula Rounding Override
|
|
145
|
+
|
|
146
|
+
Individual formulas can override the default rounding with their own `rounding` configuration:
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
const engine = new FormulaEngine({
|
|
150
|
+
defaultRounding: { mode: 'HALF_UP', precision: 2 }
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const formulas = [
|
|
154
|
+
// Override: use 4 decimal places for exchange rate
|
|
155
|
+
{ id: 'rate', expression: '1 / 3', rounding: { mode: 'HALF_UP', precision: 4 } },
|
|
156
|
+
// Uses default 2 decimal rounding
|
|
157
|
+
{ id: 'amount', expression: '1000 * $rate' },
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
const results = engine.evaluateAll(formulas, { variables: {} });
|
|
161
|
+
// rate = 0.3333 (4 decimals from per-formula config)
|
|
162
|
+
// amount = 333.30 (2 decimals from default config)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**Rounding Modes:**
|
|
166
|
+
- `HALF_UP` - Round towards nearest neighbor, ties round up (standard rounding)
|
|
167
|
+
- `HALF_DOWN` - Round towards nearest neighbor, ties round down
|
|
168
|
+
- `FLOOR` - Round towards negative infinity
|
|
169
|
+
- `CEIL` - Round towards positive infinity
|
|
170
|
+
- `NONE` - No rounding applied
|
|
171
|
+
|
|
172
|
+
#### Error Handling Behavior
|
|
173
|
+
|
|
174
|
+
Control how errors are handled during batch evaluation:
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
const formulas = [
|
|
178
|
+
{
|
|
179
|
+
id: 'ratio',
|
|
180
|
+
expression: '$a / $b',
|
|
181
|
+
onError: { type: 'ZERO' } // Return 0 on division by zero
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
id: 'result',
|
|
185
|
+
expression: '$ratio * 100', // Can continue with 0
|
|
186
|
+
},
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
const results = engine.evaluateAll(formulas, {
|
|
190
|
+
variables: { a: 10, b: 0 }
|
|
191
|
+
});
|
|
192
|
+
// ratio = 0 (instead of error)
|
|
193
|
+
// result = 0
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Error Behavior Types:**
|
|
197
|
+
- `THROW` - Propagate the error (default)
|
|
198
|
+
- `NULL` - Use `null` as the result
|
|
199
|
+
- `ZERO` - Use `0` as the result
|
|
200
|
+
- `DEFAULT` - Use `defaultValue` from the formula definition
|
|
201
|
+
- `SKIP` - Skip this formula (result is `undefined`)
|
|
202
|
+
|
|
91
203
|
### Decimal Precision
|
|
92
204
|
|
|
93
205
|
JavaScript floating-point math has precision issues:
|
|
@@ -296,6 +408,14 @@ const engine = new FormulaEngine({
|
|
|
296
408
|
divisionScale: 10, // Decimal places for division
|
|
297
409
|
},
|
|
298
410
|
|
|
411
|
+
// Default rounding for evaluateAll() intermediate values
|
|
412
|
+
// When set, all formula results in batch evaluation are rounded
|
|
413
|
+
// before being used in dependent formulas
|
|
414
|
+
defaultRounding: {
|
|
415
|
+
mode: 'HALF_UP', // Rounding mode
|
|
416
|
+
precision: 2, // Decimal places (e.g., 2 for currency)
|
|
417
|
+
},
|
|
418
|
+
|
|
299
419
|
// Security limits
|
|
300
420
|
security: {
|
|
301
421
|
maxExpressionLength: 10000,
|
|
@@ -349,8 +469,8 @@ class FormulaEngine {
|
|
|
349
469
|
// Evaluate single expression
|
|
350
470
|
evaluate(expression: string, context: EvaluationContext): EvaluationResult;
|
|
351
471
|
|
|
352
|
-
// Evaluate all formulas in order
|
|
353
|
-
evaluateAll(formulas: FormulaDefinition[], context: EvaluationContext): EvaluationResultSet;
|
|
472
|
+
// Evaluate all formulas in order (with optional rounding options)
|
|
473
|
+
evaluateAll(formulas: FormulaDefinition[], context: EvaluationContext, options?: EvaluateAllOptions): EvaluationResultSet;
|
|
354
474
|
|
|
355
475
|
// Register custom function
|
|
356
476
|
registerFunction(definition: FunctionDefinition): void;
|
package/package.json
CHANGED
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for evaluateAll() batch evaluation rounding behavior
|
|
3
|
+
*
|
|
4
|
+
* Issue: evaluateAll() batch evaluation doesn't respect rounded intermediate values
|
|
5
|
+
* in dependency chains when consumers need to round intermediate values for accuracy.
|
|
6
|
+
*
|
|
7
|
+
* @see https://github.com/monaam/formula-engine/issues/XX
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { FormulaEngine } from './formula-engine';
|
|
11
|
+
import { FormulaDefinition, EvaluationContext, RoundingConfig } from './types';
|
|
12
|
+
import { Decimal } from './decimal-utils';
|
|
13
|
+
|
|
14
|
+
describe('evaluateAll() Intermediate Value Rounding', () => {
|
|
15
|
+
let engine: FormulaEngine;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
engine = new FormulaEngine();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('Current Behavior - Unrounded Intermediate Values', () => {
|
|
22
|
+
it('should demonstrate the issue: unrounded values propagate through dependency chain', () => {
|
|
23
|
+
// This test documents the current (problematic) behavior
|
|
24
|
+
// In financial calculations, intermediate values should be rounded to cents
|
|
25
|
+
|
|
26
|
+
const formulas: FormulaDefinition[] = [
|
|
27
|
+
{ id: '_discount1', expression: '127.5 * 0.15' }, // = 19.125
|
|
28
|
+
{ id: '_discountTotal', expression: '$_discount1' }, // = 19.125 (raw)
|
|
29
|
+
{ id: 'totalHt', expression: '127.5 - $_discountTotal' }, // = 108.375 (using raw)
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const results = engine.evaluateAll(formulas, { variables: {} });
|
|
33
|
+
|
|
34
|
+
expect(results.success).toBe(true);
|
|
35
|
+
|
|
36
|
+
// Current behavior: raw unrounded values propagate
|
|
37
|
+
expect((results.results.get('_discount1')?.value as Decimal).toNumber()).toBe(19.125);
|
|
38
|
+
expect((results.results.get('_discountTotal')?.value as Decimal).toNumber()).toBe(19.125);
|
|
39
|
+
expect((results.results.get('totalHt')?.value as Decimal).toNumber()).toBe(108.375);
|
|
40
|
+
|
|
41
|
+
// Expected for financial calculations:
|
|
42
|
+
// _discount1 = 19.125 → round to 19.13
|
|
43
|
+
// _discountTotal = 19.13
|
|
44
|
+
// totalHt = 127.5 - 19.13 = 108.37
|
|
45
|
+
//
|
|
46
|
+
// But we get 108.375 instead of 108.37
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should show accumulated rounding errors in complex financial chain', () => {
|
|
50
|
+
// Multiple line items each with small rounding differences that accumulate
|
|
51
|
+
const formulas: FormulaDefinition[] = [
|
|
52
|
+
// Line item 1: quantity 3 at price 10.33
|
|
53
|
+
{ id: 'line1_subtotal', expression: '3 * 10.33' }, // = 30.99
|
|
54
|
+
{ id: 'line1_tax', expression: '$line1_subtotal * 0.0825' }, // = 2.556675 (should be 2.56)
|
|
55
|
+
|
|
56
|
+
// Line item 2: quantity 7 at price 5.67
|
|
57
|
+
{ id: 'line2_subtotal', expression: '7 * 5.67' }, // = 39.69
|
|
58
|
+
{ id: 'line2_tax', expression: '$line2_subtotal * 0.0825' }, // = 3.274425 (should be 3.27)
|
|
59
|
+
|
|
60
|
+
// Total tax should use rounded line item taxes
|
|
61
|
+
{ id: 'totalTax', expression: '$line1_tax + $line2_tax' }, // = 5.8311 (should be 5.83)
|
|
62
|
+
|
|
63
|
+
// Grand total
|
|
64
|
+
{ id: 'grandTotal', expression: '$line1_subtotal + $line2_subtotal + $totalTax' },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const results = engine.evaluateAll(formulas, { variables: {} });
|
|
68
|
+
|
|
69
|
+
expect(results.success).toBe(true);
|
|
70
|
+
|
|
71
|
+
// Raw calculated values (what we currently get)
|
|
72
|
+
const line1Tax = (results.results.get('line1_tax')?.value as Decimal).toNumber();
|
|
73
|
+
const line2Tax = (results.results.get('line2_tax')?.value as Decimal).toNumber();
|
|
74
|
+
const totalTax = (results.results.get('totalTax')?.value as Decimal).toNumber();
|
|
75
|
+
|
|
76
|
+
// These are NOT rounded to 2 decimal places as financial calculations require
|
|
77
|
+
expect(line1Tax).toBeCloseTo(2.556675, 6);
|
|
78
|
+
expect(line2Tax).toBeCloseTo(3.274425, 6);
|
|
79
|
+
expect(totalTax).toBeCloseTo(5.8311, 4);
|
|
80
|
+
|
|
81
|
+
// For proper financial calculation:
|
|
82
|
+
// line1_tax should be 2.56 (rounded)
|
|
83
|
+
// line2_tax should be 3.27 (rounded)
|
|
84
|
+
// totalTax should be 2.56 + 3.27 = 5.83
|
|
85
|
+
//
|
|
86
|
+
// Difference of 0.0011 may seem small, but accumulates across many line items
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should show discrepancy between batch and sequential evaluation with manual rounding', () => {
|
|
90
|
+
// When evaluating sequentially with manual rounding between steps,
|
|
91
|
+
// we get different results than batch evaluation
|
|
92
|
+
|
|
93
|
+
const basePrice = 127.5;
|
|
94
|
+
const discountRate = 0.15;
|
|
95
|
+
|
|
96
|
+
// Sequential evaluation with rounding
|
|
97
|
+
const discount1 = new Decimal(basePrice).times(discountRate).toDecimalPlaces(2, Decimal.ROUND_HALF_UP);
|
|
98
|
+
const discountTotal = discount1; // Already rounded
|
|
99
|
+
const totalHtSequential = new Decimal(basePrice).minus(discountTotal).toNumber();
|
|
100
|
+
|
|
101
|
+
// Batch evaluation
|
|
102
|
+
const formulas: FormulaDefinition[] = [
|
|
103
|
+
{ id: '_discount1', expression: `${basePrice} * ${discountRate}` },
|
|
104
|
+
{ id: '_discountTotal', expression: '$_discount1' },
|
|
105
|
+
{ id: 'totalHt', expression: `${basePrice} - $_discountTotal` },
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
const results = engine.evaluateAll(formulas, { variables: {} });
|
|
109
|
+
const totalHtBatch = (results.results.get('totalHt')?.value as Decimal).toNumber();
|
|
110
|
+
|
|
111
|
+
// Sequential with rounding: 127.5 - 19.13 = 108.37
|
|
112
|
+
expect(totalHtSequential).toBe(108.37);
|
|
113
|
+
|
|
114
|
+
// Batch without intermediate rounding: 127.5 - 19.125 = 108.375
|
|
115
|
+
expect(totalHtBatch).toBe(108.375);
|
|
116
|
+
|
|
117
|
+
// They are NOT equal - this is the core issue
|
|
118
|
+
expect(totalHtSequential).not.toBe(totalHtBatch);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('Workaround - Per-Formula Rounding Configuration', () => {
|
|
123
|
+
it('should apply rounding when configured on individual formulas', () => {
|
|
124
|
+
// The existing rounding config on FormulaDefinition DOES work
|
|
125
|
+
// but requires specifying it on every intermediate formula
|
|
126
|
+
|
|
127
|
+
const roundConfig: RoundingConfig = { mode: 'HALF_UP', precision: 2 };
|
|
128
|
+
|
|
129
|
+
const formulas: FormulaDefinition[] = [
|
|
130
|
+
{ id: '_discount1', expression: '127.5 * 0.15', rounding: roundConfig },
|
|
131
|
+
{ id: '_discountTotal', expression: '$_discount1', rounding: roundConfig },
|
|
132
|
+
{ id: 'totalHt', expression: '127.5 - $_discountTotal', rounding: roundConfig },
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
const results = engine.evaluateAll(formulas, { variables: {} });
|
|
136
|
+
|
|
137
|
+
expect(results.success).toBe(true);
|
|
138
|
+
|
|
139
|
+
// With per-formula rounding configured, values ARE rounded
|
|
140
|
+
expect((results.results.get('_discount1')?.value as Decimal).toNumber()).toBe(19.13);
|
|
141
|
+
expect((results.results.get('_discountTotal')?.value as Decimal).toNumber()).toBe(19.13);
|
|
142
|
+
expect((results.results.get('totalHt')?.value as Decimal).toNumber()).toBe(108.37);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should propagate rounded values to dependent formulas', () => {
|
|
146
|
+
// Verify that rounded values (not raw) are used in subsequent calculations
|
|
147
|
+
|
|
148
|
+
const roundConfig: RoundingConfig = { mode: 'HALF_UP', precision: 2 };
|
|
149
|
+
|
|
150
|
+
const formulas: FormulaDefinition[] = [
|
|
151
|
+
{ id: 'line1_subtotal', expression: '3 * 10.33', rounding: roundConfig },
|
|
152
|
+
{ id: 'line1_tax', expression: '$line1_subtotal * 0.0825', rounding: roundConfig },
|
|
153
|
+
|
|
154
|
+
{ id: 'line2_subtotal', expression: '7 * 5.67', rounding: roundConfig },
|
|
155
|
+
{ id: 'line2_tax', expression: '$line2_subtotal * 0.0825', rounding: roundConfig },
|
|
156
|
+
|
|
157
|
+
{ id: 'totalTax', expression: '$line1_tax + $line2_tax', rounding: roundConfig },
|
|
158
|
+
{ id: 'grandTotal', expression: '$line1_subtotal + $line2_subtotal + $totalTax', rounding: roundConfig },
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
const results = engine.evaluateAll(formulas, { variables: {} });
|
|
162
|
+
|
|
163
|
+
expect(results.success).toBe(true);
|
|
164
|
+
|
|
165
|
+
// All values should be properly rounded to 2 decimal places
|
|
166
|
+
expect((results.results.get('line1_subtotal')?.value as Decimal).toNumber()).toBe(30.99);
|
|
167
|
+
expect((results.results.get('line1_tax')?.value as Decimal).toNumber()).toBe(2.56);
|
|
168
|
+
expect((results.results.get('line2_subtotal')?.value as Decimal).toNumber()).toBe(39.69);
|
|
169
|
+
expect((results.results.get('line2_tax')?.value as Decimal).toNumber()).toBe(3.27);
|
|
170
|
+
expect((results.results.get('totalTax')?.value as Decimal).toNumber()).toBe(5.83);
|
|
171
|
+
expect((results.results.get('grandTotal')?.value as Decimal).toNumber()).toBe(76.51);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should be tedious to specify rounding on many formulas', () => {
|
|
175
|
+
// This test documents why the feature request makes sense:
|
|
176
|
+
// Having to specify rounding on every formula is verbose and error-prone
|
|
177
|
+
|
|
178
|
+
const roundConfig: RoundingConfig = { mode: 'HALF_UP', precision: 2 };
|
|
179
|
+
|
|
180
|
+
// In a real application, you might have 50+ formulas
|
|
181
|
+
// Forgetting rounding on ANY intermediate formula breaks the chain
|
|
182
|
+
const formulas: FormulaDefinition[] = [
|
|
183
|
+
{ id: 'unitPrice', expression: '$basePrice / $packageQty', rounding: roundConfig },
|
|
184
|
+
{ id: 'lineSubtotal', expression: '$unitPrice * $qty', rounding: roundConfig },
|
|
185
|
+
{ id: 'discountAmount', expression: '$lineSubtotal * $discountPct / 100', rounding: roundConfig },
|
|
186
|
+
{ id: 'afterDiscount', expression: '$lineSubtotal - $discountAmount', rounding: roundConfig },
|
|
187
|
+
{ id: 'taxableAmount', expression: '$afterDiscount', rounding: roundConfig },
|
|
188
|
+
{ id: 'stateTax', expression: '$taxableAmount * $stateTaxRate', rounding: roundConfig },
|
|
189
|
+
{ id: 'localTax', expression: '$taxableAmount * $localTaxRate', rounding: roundConfig },
|
|
190
|
+
{ id: 'totalTax', expression: '$stateTax + $localTax', rounding: roundConfig },
|
|
191
|
+
{ id: 'lineTotal', expression: '$afterDiscount + $totalTax', rounding: roundConfig },
|
|
192
|
+
// ... many more formulas in a real system
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
const context: EvaluationContext = {
|
|
196
|
+
variables: {
|
|
197
|
+
basePrice: 99.99,
|
|
198
|
+
packageQty: 3,
|
|
199
|
+
qty: 7,
|
|
200
|
+
discountPct: 10,
|
|
201
|
+
stateTaxRate: 0.0625,
|
|
202
|
+
localTaxRate: 0.02,
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const results = engine.evaluateAll(formulas, context);
|
|
207
|
+
expect(results.success).toBe(true);
|
|
208
|
+
|
|
209
|
+
// Every formula needs rounding: { mode: 'HALF_UP', precision: 2 }
|
|
210
|
+
// This is repetitive and easy to forget
|
|
211
|
+
expect(formulas.every(f => f.rounding !== undefined)).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('Default Rounding Configuration', () => {
|
|
216
|
+
it('should apply defaultRounding to all intermediate values when configured', () => {
|
|
217
|
+
const engineWithRounding = new FormulaEngine({
|
|
218
|
+
defaultRounding: { mode: 'HALF_UP', precision: 2 }
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const formulas: FormulaDefinition[] = [
|
|
222
|
+
{ id: '_discount1', expression: '127.5 * 0.15' },
|
|
223
|
+
{ id: '_discountTotal', expression: '$_discount1' },
|
|
224
|
+
{ id: 'totalHt', expression: '127.5 - $_discountTotal' },
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
const results = engineWithRounding.evaluateAll(formulas, { variables: {} });
|
|
228
|
+
|
|
229
|
+
expect(results.success).toBe(true);
|
|
230
|
+
// All values should be rounded to 2 decimal places
|
|
231
|
+
expect((results.results.get('_discount1')?.value as Decimal).toNumber()).toBe(19.13);
|
|
232
|
+
expect((results.results.get('_discountTotal')?.value as Decimal).toNumber()).toBe(19.13);
|
|
233
|
+
expect((results.results.get('totalHt')?.value as Decimal).toNumber()).toBe(108.37);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should apply defaultRounding to tax calculations', () => {
|
|
237
|
+
const engineWithRounding = new FormulaEngine({
|
|
238
|
+
defaultRounding: { mode: 'HALF_UP', precision: 2 }
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const formulas: FormulaDefinition[] = [
|
|
242
|
+
{ id: 'line1_tax', expression: '30.99 * 0.0825' },
|
|
243
|
+
{ id: 'line2_tax', expression: '39.69 * 0.0825' },
|
|
244
|
+
{ id: 'totalTax', expression: '$line1_tax + $line2_tax' },
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
const results = engineWithRounding.evaluateAll(formulas, { variables: {} });
|
|
248
|
+
|
|
249
|
+
expect(results.success).toBe(true);
|
|
250
|
+
expect((results.results.get('line1_tax')?.value as Decimal).toNumber()).toBe(2.56);
|
|
251
|
+
expect((results.results.get('line2_tax')?.value as Decimal).toNumber()).toBe(3.27);
|
|
252
|
+
expect((results.results.get('totalTax')?.value as Decimal).toNumber()).toBe(5.83);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should allow per-formula override of defaultRounding', () => {
|
|
256
|
+
const engineWithRounding = new FormulaEngine({
|
|
257
|
+
defaultRounding: { mode: 'HALF_UP', precision: 2 }
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Use 1/3 which is 0.3333... to clearly show precision difference
|
|
261
|
+
// At 4 decimals: 0.3333, at 2 decimals: 0.33
|
|
262
|
+
const formulas: FormulaDefinition[] = [
|
|
263
|
+
{ id: 'rate', expression: '1 / 3', rounding: { mode: 'HALF_UP', precision: 4 } },
|
|
264
|
+
{ id: 'amount', expression: '1000 * $rate' }, // Uses default 2 decimal rounding
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
const results = engineWithRounding.evaluateAll(formulas, { variables: {} });
|
|
268
|
+
|
|
269
|
+
expect(results.success).toBe(true);
|
|
270
|
+
// Per-formula rounding to 4 decimals: 1/3 = 0.3333
|
|
271
|
+
expect((results.results.get('rate')?.value as Decimal).toNumber()).toBe(0.3333);
|
|
272
|
+
// amount = 1000 * 0.3333 = 333.3, rounded to 2 decimals = 333.30
|
|
273
|
+
// If default rounding was applied to rate (0.33), amount would be 330
|
|
274
|
+
expect((results.results.get('amount')?.value as Decimal).toNumber()).toBe(333.3);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('should disable intermediate rounding with disableIntermediateRounding option', () => {
|
|
278
|
+
const engineWithRounding = new FormulaEngine({
|
|
279
|
+
defaultRounding: { mode: 'HALF_UP', precision: 2 }
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const formulas: FormulaDefinition[] = [
|
|
283
|
+
{ id: '_discount1', expression: '127.5 * 0.15' },
|
|
284
|
+
{ id: '_discountTotal', expression: '$_discount1' },
|
|
285
|
+
{ id: 'totalHt', expression: '127.5 - $_discountTotal' },
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
// Disable intermediate rounding
|
|
289
|
+
const results = engineWithRounding.evaluateAll(formulas, { variables: {} }, {
|
|
290
|
+
disableIntermediateRounding: true
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(results.success).toBe(true);
|
|
294
|
+
// Raw unrounded values should propagate (like the original behavior)
|
|
295
|
+
expect((results.results.get('_discount1')?.value as Decimal).toNumber()).toBe(19.125);
|
|
296
|
+
expect((results.results.get('_discountTotal')?.value as Decimal).toNumber()).toBe(19.125);
|
|
297
|
+
expect((results.results.get('totalHt')?.value as Decimal).toNumber()).toBe(108.375);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('should still apply per-formula rounding when disableIntermediateRounding is true', () => {
|
|
301
|
+
const engineWithRounding = new FormulaEngine({
|
|
302
|
+
defaultRounding: { mode: 'HALF_UP', precision: 2 }
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const formulas: FormulaDefinition[] = [
|
|
306
|
+
{ id: '_discount1', expression: '127.5 * 0.15', rounding: { mode: 'HALF_UP', precision: 2 } },
|
|
307
|
+
{ id: '_discountTotal', expression: '$_discount1' }, // No per-formula rounding
|
|
308
|
+
{ id: 'totalHt', expression: '127.5 - $_discountTotal' },
|
|
309
|
+
];
|
|
310
|
+
|
|
311
|
+
const results = engineWithRounding.evaluateAll(formulas, { variables: {} }, {
|
|
312
|
+
disableIntermediateRounding: true
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
expect(results.success).toBe(true);
|
|
316
|
+
// Per-formula rounding is still applied
|
|
317
|
+
expect((results.results.get('_discount1')?.value as Decimal).toNumber()).toBe(19.13);
|
|
318
|
+
// But default rounding is disabled, so _discountTotal uses raw value from context
|
|
319
|
+
// which is the rounded 19.13 from the previous formula
|
|
320
|
+
expect((results.results.get('_discountTotal')?.value as Decimal).toNumber()).toBe(19.13);
|
|
321
|
+
expect((results.results.get('totalHt')?.value as Decimal).toNumber()).toBe(108.37);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should not apply defaultRounding when mode is NONE', () => {
|
|
325
|
+
const engineWithNoneRounding = new FormulaEngine({
|
|
326
|
+
defaultRounding: { mode: 'NONE', precision: 2 }
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const formulas: FormulaDefinition[] = [
|
|
330
|
+
{ id: '_discount1', expression: '127.5 * 0.15' },
|
|
331
|
+
{ id: 'totalHt', expression: '127.5 - $_discount1' },
|
|
332
|
+
];
|
|
333
|
+
|
|
334
|
+
const results = engineWithNoneRounding.evaluateAll(formulas, { variables: {} });
|
|
335
|
+
|
|
336
|
+
expect(results.success).toBe(true);
|
|
337
|
+
// NONE mode should not round
|
|
338
|
+
expect((results.results.get('_discount1')?.value as Decimal).toNumber()).toBe(19.125);
|
|
339
|
+
expect((results.results.get('totalHt')?.value as Decimal).toNumber()).toBe(108.375);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('should match sequential evaluation when defaultRounding is configured', () => {
|
|
343
|
+
const engineWithRounding = new FormulaEngine({
|
|
344
|
+
defaultRounding: { mode: 'HALF_UP', precision: 2 }
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const basePrice = 127.5;
|
|
348
|
+
const discountRate = 0.15;
|
|
349
|
+
|
|
350
|
+
// Sequential evaluation with manual rounding
|
|
351
|
+
const discount1 = new Decimal(basePrice).times(discountRate).toDecimalPlaces(2, Decimal.ROUND_HALF_UP);
|
|
352
|
+
const discountTotal = discount1;
|
|
353
|
+
const totalHtSequential = new Decimal(basePrice).minus(discountTotal).toDecimalPlaces(2, Decimal.ROUND_HALF_UP).toNumber();
|
|
354
|
+
|
|
355
|
+
// Batch evaluation with defaultRounding
|
|
356
|
+
const formulas: FormulaDefinition[] = [
|
|
357
|
+
{ id: '_discount1', expression: `${basePrice} * ${discountRate}` },
|
|
358
|
+
{ id: '_discountTotal', expression: '$_discount1' },
|
|
359
|
+
{ id: 'totalHt', expression: `${basePrice} - $_discountTotal` },
|
|
360
|
+
];
|
|
361
|
+
|
|
362
|
+
const results = engineWithRounding.evaluateAll(formulas, { variables: {} });
|
|
363
|
+
const totalHtBatch = (results.results.get('totalHt')?.value as Decimal).toNumber();
|
|
364
|
+
|
|
365
|
+
// Sequential with rounding: 127.5 - 19.13 = 108.37
|
|
366
|
+
expect(totalHtSequential).toBe(108.37);
|
|
367
|
+
|
|
368
|
+
// Batch with defaultRounding should now match!
|
|
369
|
+
expect(totalHtBatch).toBe(108.37);
|
|
370
|
+
expect(totalHtSequential).toBe(totalHtBatch);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('should handle complex financial chain with defaultRounding', () => {
|
|
374
|
+
const engineWithRounding = new FormulaEngine({
|
|
375
|
+
defaultRounding: { mode: 'HALF_UP', precision: 2 }
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const formulas: FormulaDefinition[] = [
|
|
379
|
+
// Line item 1: quantity 3 at price 10.33
|
|
380
|
+
{ id: 'line1_subtotal', expression: '3 * 10.33' },
|
|
381
|
+
{ id: 'line1_tax', expression: '$line1_subtotal * 0.0825' },
|
|
382
|
+
|
|
383
|
+
// Line item 2: quantity 7 at price 5.67
|
|
384
|
+
{ id: 'line2_subtotal', expression: '7 * 5.67' },
|
|
385
|
+
{ id: 'line2_tax', expression: '$line2_subtotal * 0.0825' },
|
|
386
|
+
|
|
387
|
+
// Total tax uses rounded line item taxes
|
|
388
|
+
{ id: 'totalTax', expression: '$line1_tax + $line2_tax' },
|
|
389
|
+
|
|
390
|
+
// Grand total
|
|
391
|
+
{ id: 'grandTotal', expression: '$line1_subtotal + $line2_subtotal + $totalTax' },
|
|
392
|
+
];
|
|
393
|
+
|
|
394
|
+
const results = engineWithRounding.evaluateAll(formulas, { variables: {} });
|
|
395
|
+
|
|
396
|
+
expect(results.success).toBe(true);
|
|
397
|
+
|
|
398
|
+
// All values properly rounded to 2 decimal places
|
|
399
|
+
expect((results.results.get('line1_subtotal')?.value as Decimal).toNumber()).toBe(30.99);
|
|
400
|
+
expect((results.results.get('line1_tax')?.value as Decimal).toNumber()).toBe(2.56);
|
|
401
|
+
expect((results.results.get('line2_subtotal')?.value as Decimal).toNumber()).toBe(39.69);
|
|
402
|
+
expect((results.results.get('line2_tax')?.value as Decimal).toNumber()).toBe(3.27);
|
|
403
|
+
expect((results.results.get('totalTax')?.value as Decimal).toNumber()).toBe(5.83);
|
|
404
|
+
expect((results.results.get('grandTotal')?.value as Decimal).toNumber()).toBe(76.51);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
describe('Edge Cases', () => {
|
|
409
|
+
it('should handle rounding mode NONE correctly', () => {
|
|
410
|
+
const formulas: FormulaDefinition[] = [
|
|
411
|
+
{ id: 'calc', expression: '1 / 3', rounding: { mode: 'NONE', precision: 2 } },
|
|
412
|
+
];
|
|
413
|
+
|
|
414
|
+
const results = engine.evaluateAll(formulas, { variables: {} });
|
|
415
|
+
|
|
416
|
+
// NONE mode should not round
|
|
417
|
+
const value = (results.results.get('calc')?.value as Decimal).toString();
|
|
418
|
+
expect(value.length).toBeGreaterThan(5); // Not truncated to 2 decimals
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('should handle rounding with FLOOR mode', () => {
|
|
422
|
+
const formulas: FormulaDefinition[] = [
|
|
423
|
+
{ id: 'calc', expression: '19.129', rounding: { mode: 'FLOOR', precision: 2 } },
|
|
424
|
+
];
|
|
425
|
+
|
|
426
|
+
const results = engine.evaluateAll(formulas, { variables: {} });
|
|
427
|
+
expect((results.results.get('calc')?.value as Decimal).toNumber()).toBe(19.12);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('should handle rounding with CEIL mode', () => {
|
|
431
|
+
const formulas: FormulaDefinition[] = [
|
|
432
|
+
{ id: 'calc', expression: '19.121', rounding: { mode: 'CEIL', precision: 2 } },
|
|
433
|
+
];
|
|
434
|
+
|
|
435
|
+
const results = engine.evaluateAll(formulas, { variables: {} });
|
|
436
|
+
expect((results.results.get('calc')?.value as Decimal).toNumber()).toBe(19.13);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('should handle mixed rounding configurations in chain', () => {
|
|
440
|
+
const formulas: FormulaDefinition[] = [
|
|
441
|
+
{ id: 'a', expression: '10.555', rounding: { mode: 'HALF_UP', precision: 2 } }, // 10.56
|
|
442
|
+
{ id: 'b', expression: '$a * 2', rounding: { mode: 'FLOOR', precision: 1 } }, // 21.1 (floor of 21.12)
|
|
443
|
+
{ id: 'c', expression: '$b + 0.99', rounding: { mode: 'CEIL', precision: 0 } }, // 23 (ceil of 22.09)
|
|
444
|
+
];
|
|
445
|
+
|
|
446
|
+
const results = engine.evaluateAll(formulas, { variables: {} });
|
|
447
|
+
|
|
448
|
+
expect((results.results.get('a')?.value as Decimal).toNumber()).toBe(10.56);
|
|
449
|
+
expect((results.results.get('b')?.value as Decimal).toNumber()).toBe(21.1);
|
|
450
|
+
expect((results.results.get('c')?.value as Decimal).toNumber()).toBe(23);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('should propagate rounded value even when dependent formula has no rounding', () => {
|
|
454
|
+
// If formula A has rounding, formula B that depends on A should see the rounded value
|
|
455
|
+
const formulas: FormulaDefinition[] = [
|
|
456
|
+
{ id: 'a', expression: '19.125', rounding: { mode: 'HALF_UP', precision: 2 } },
|
|
457
|
+
{ id: 'b', expression: '$a * 2' }, // No rounding on b, but should use rounded a
|
|
458
|
+
];
|
|
459
|
+
|
|
460
|
+
const results = engine.evaluateAll(formulas, { variables: {} });
|
|
461
|
+
|
|
462
|
+
expect((results.results.get('a')?.value as Decimal).toNumber()).toBe(19.13);
|
|
463
|
+
// b should be 19.13 * 2 = 38.26, NOT 19.125 * 2 = 38.25
|
|
464
|
+
expect((results.results.get('b')?.value as Decimal).toNumber()).toBe(38.26);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('should handle non-numeric results without rounding errors', () => {
|
|
468
|
+
const formulas: FormulaDefinition[] = [
|
|
469
|
+
{ id: 'amount', expression: '19.125', rounding: { mode: 'HALF_UP', precision: 2 } },
|
|
470
|
+
{ id: 'label', expression: '"Total: " + STRING($amount)' },
|
|
471
|
+
{ id: 'isPositive', expression: '$amount > 0' },
|
|
472
|
+
];
|
|
473
|
+
|
|
474
|
+
const results = engine.evaluateAll(formulas, { variables: {} });
|
|
475
|
+
|
|
476
|
+
expect(results.success).toBe(true);
|
|
477
|
+
expect((results.results.get('amount')?.value as Decimal).toNumber()).toBe(19.13);
|
|
478
|
+
expect(results.results.get('label')?.value).toBe('Total: 19.13');
|
|
479
|
+
expect(results.results.get('isPositive')?.value).toBe(true);
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
describe('Real-World Financial Scenarios', () => {
|
|
484
|
+
it('should calculate invoice with proper rounding for accounting', () => {
|
|
485
|
+
const roundConfig: RoundingConfig = { mode: 'HALF_UP', precision: 2 };
|
|
486
|
+
|
|
487
|
+
const formulas: FormulaDefinition[] = [
|
|
488
|
+
// Line items
|
|
489
|
+
{ id: 'line1', expression: '$qty1 * $price1', rounding: roundConfig },
|
|
490
|
+
{ id: 'line2', expression: '$qty2 * $price2', rounding: roundConfig },
|
|
491
|
+
{ id: 'line3', expression: '$qty3 * $price3', rounding: roundConfig },
|
|
492
|
+
|
|
493
|
+
// Subtotal
|
|
494
|
+
{ id: 'subtotal', expression: '$line1 + $line2 + $line3', rounding: roundConfig },
|
|
495
|
+
|
|
496
|
+
// Discount (percentage)
|
|
497
|
+
{ id: 'discountAmount', expression: '$subtotal * $discountPct / 100', rounding: roundConfig },
|
|
498
|
+
{ id: 'afterDiscount', expression: '$subtotal - $discountAmount', rounding: roundConfig },
|
|
499
|
+
|
|
500
|
+
// Tax calculations (separate state and local)
|
|
501
|
+
{ id: 'stateTax', expression: '$afterDiscount * 0.0625', rounding: roundConfig },
|
|
502
|
+
{ id: 'localTax', expression: '$afterDiscount * 0.0225', rounding: roundConfig },
|
|
503
|
+
{ id: 'totalTax', expression: '$stateTax + $localTax', rounding: roundConfig },
|
|
504
|
+
|
|
505
|
+
// Final total
|
|
506
|
+
{ id: 'grandTotal', expression: '$afterDiscount + $totalTax', rounding: roundConfig },
|
|
507
|
+
];
|
|
508
|
+
|
|
509
|
+
const context: EvaluationContext = {
|
|
510
|
+
variables: {
|
|
511
|
+
qty1: 3, price1: 29.99, // 89.97
|
|
512
|
+
qty2: 2, price2: 49.95, // 99.90
|
|
513
|
+
qty3: 5, price3: 9.99, // 49.95
|
|
514
|
+
discountPct: 15, // 15% discount
|
|
515
|
+
},
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
const results = engine.evaluateAll(formulas, context);
|
|
519
|
+
|
|
520
|
+
expect(results.success).toBe(true);
|
|
521
|
+
|
|
522
|
+
// Verify each step is properly rounded
|
|
523
|
+
expect((results.results.get('line1')?.value as Decimal).toNumber()).toBe(89.97);
|
|
524
|
+
expect((results.results.get('line2')?.value as Decimal).toNumber()).toBe(99.90);
|
|
525
|
+
expect((results.results.get('line3')?.value as Decimal).toNumber()).toBe(49.95);
|
|
526
|
+
expect((results.results.get('subtotal')?.value as Decimal).toNumber()).toBe(239.82);
|
|
527
|
+
expect((results.results.get('discountAmount')?.value as Decimal).toNumber()).toBe(35.97); // 239.82 * 0.15 = 35.973 -> 35.97
|
|
528
|
+
expect((results.results.get('afterDiscount')?.value as Decimal).toNumber()).toBe(203.85);
|
|
529
|
+
expect((results.results.get('stateTax')?.value as Decimal).toNumber()).toBe(12.74); // 203.85 * 0.0625 = 12.740625 -> 12.74
|
|
530
|
+
expect((results.results.get('localTax')?.value as Decimal).toNumber()).toBe(4.59); // 203.85 * 0.0225 = 4.586625 -> 4.59
|
|
531
|
+
expect((results.results.get('totalTax')?.value as Decimal).toNumber()).toBe(17.33);
|
|
532
|
+
expect((results.results.get('grandTotal')?.value as Decimal).toNumber()).toBe(221.18);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('should handle currency conversion with proper intermediate rounding', () => {
|
|
536
|
+
const roundConfig: RoundingConfig = { mode: 'HALF_UP', precision: 2 };
|
|
537
|
+
|
|
538
|
+
const formulas: FormulaDefinition[] = [
|
|
539
|
+
// Amount in USD
|
|
540
|
+
{ id: 'usdAmount', expression: '$baseAmount * $usdRate', rounding: roundConfig },
|
|
541
|
+
|
|
542
|
+
// Convert USD to EUR (intermediate conversion)
|
|
543
|
+
{ id: 'eurAmount', expression: '$usdAmount * $eurRate', rounding: roundConfig },
|
|
544
|
+
|
|
545
|
+
// Add VAT in EUR
|
|
546
|
+
{ id: 'vatAmount', expression: '$eurAmount * 0.20', rounding: roundConfig },
|
|
547
|
+
{ id: 'totalEur', expression: '$eurAmount + $vatAmount', rounding: roundConfig },
|
|
548
|
+
];
|
|
549
|
+
|
|
550
|
+
const context: EvaluationContext = {
|
|
551
|
+
variables: {
|
|
552
|
+
baseAmount: 1000,
|
|
553
|
+
usdRate: 1.0, // 1:1 for simplicity
|
|
554
|
+
eurRate: 0.92, // USD to EUR
|
|
555
|
+
},
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
const results = engine.evaluateAll(formulas, context);
|
|
559
|
+
|
|
560
|
+
expect(results.success).toBe(true);
|
|
561
|
+
expect((results.results.get('usdAmount')?.value as Decimal).toNumber()).toBe(1000);
|
|
562
|
+
expect((results.results.get('eurAmount')?.value as Decimal).toNumber()).toBe(920);
|
|
563
|
+
expect((results.results.get('vatAmount')?.value as Decimal).toNumber()).toBe(184);
|
|
564
|
+
expect((results.results.get('totalEur')?.value as Decimal).toNumber()).toBe(1104);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it('should handle commission calculations with tiered rates', () => {
|
|
568
|
+
const roundConfig: RoundingConfig = { mode: 'HALF_UP', precision: 2 };
|
|
569
|
+
|
|
570
|
+
const formulas: FormulaDefinition[] = [
|
|
571
|
+
// Base commission
|
|
572
|
+
{ id: 'baseCommission', expression: '$saleAmount * 0.05', rounding: roundConfig },
|
|
573
|
+
|
|
574
|
+
// Bonus for exceeding threshold
|
|
575
|
+
{ id: 'bonusEligible', expression: '$saleAmount > 10000 ? ($saleAmount - 10000) * 0.02 : 0', rounding: roundConfig },
|
|
576
|
+
|
|
577
|
+
// Total commission
|
|
578
|
+
{ id: 'totalCommission', expression: '$baseCommission + $bonusEligible', rounding: roundConfig },
|
|
579
|
+
|
|
580
|
+
// Tax withholding
|
|
581
|
+
{ id: 'taxWithholding', expression: '$totalCommission * 0.22', rounding: roundConfig },
|
|
582
|
+
|
|
583
|
+
// Net payout
|
|
584
|
+
{ id: 'netPayout', expression: '$totalCommission - $taxWithholding', rounding: roundConfig },
|
|
585
|
+
];
|
|
586
|
+
|
|
587
|
+
const context: EvaluationContext = {
|
|
588
|
+
variables: {
|
|
589
|
+
saleAmount: 15750.50,
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const results = engine.evaluateAll(formulas, context);
|
|
594
|
+
|
|
595
|
+
expect(results.success).toBe(true);
|
|
596
|
+
// 15750.50 * 0.05 = 787.525 -> 787.53
|
|
597
|
+
expect((results.results.get('baseCommission')?.value as Decimal).toNumber()).toBe(787.53);
|
|
598
|
+
// (15750.50 - 10000) * 0.02 = 115.01
|
|
599
|
+
expect((results.results.get('bonusEligible')?.value as Decimal).toNumber()).toBe(115.01);
|
|
600
|
+
// 787.53 + 115.01 = 902.54
|
|
601
|
+
expect((results.results.get('totalCommission')?.value as Decimal).toNumber()).toBe(902.54);
|
|
602
|
+
// 902.54 * 0.22 = 198.5588 -> 198.56
|
|
603
|
+
expect((results.results.get('taxWithholding')?.value as Decimal).toNumber()).toBe(198.56);
|
|
604
|
+
// 902.54 - 198.56 = 703.98
|
|
605
|
+
expect((results.results.get('netPayout')?.value as Decimal).toNumber()).toBe(703.98);
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
});
|
package/src/formula-engine.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
EvaluationContext,
|
|
5
5
|
EvaluationResult,
|
|
6
6
|
EvaluationResultSet,
|
|
7
|
+
EvaluateAllOptions,
|
|
7
8
|
ValidationResult,
|
|
8
9
|
FunctionDefinition,
|
|
9
10
|
ASTNode,
|
|
@@ -182,10 +183,15 @@ export class FormulaEngine {
|
|
|
182
183
|
|
|
183
184
|
/**
|
|
184
185
|
* Evaluate all formulas in dependency order
|
|
186
|
+
*
|
|
187
|
+
* @param formulas - Array of formula definitions to evaluate
|
|
188
|
+
* @param context - Evaluation context with variables
|
|
189
|
+
* @param options - Optional configuration for batch evaluation
|
|
185
190
|
*/
|
|
186
191
|
evaluateAll(
|
|
187
192
|
formulas: FormulaDefinition[],
|
|
188
|
-
context: EvaluationContext
|
|
193
|
+
context: EvaluationContext,
|
|
194
|
+
options?: EvaluateAllOptions
|
|
189
195
|
): EvaluationResultSet {
|
|
190
196
|
const startTime = Date.now();
|
|
191
197
|
const results = new Map<string, EvaluationResult>();
|
|
@@ -211,6 +217,12 @@ export class FormulaEngine {
|
|
|
211
217
|
formulaMap.set(formula.id, formula);
|
|
212
218
|
}
|
|
213
219
|
|
|
220
|
+
// Determine if intermediate rounding should be applied
|
|
221
|
+
const applyIntermediateRounding =
|
|
222
|
+
this.config.defaultRounding &&
|
|
223
|
+
this.config.defaultRounding.mode !== 'NONE' &&
|
|
224
|
+
!options?.disableIntermediateRounding;
|
|
225
|
+
|
|
214
226
|
// Evaluate in order, merging results into context
|
|
215
227
|
const workingContext: EvaluationContext = this.normalizeContext(context);
|
|
216
228
|
|
|
@@ -221,10 +233,16 @@ export class FormulaEngine {
|
|
|
221
233
|
try {
|
|
222
234
|
const result = this.evaluator.evaluate(formula.expression, workingContext);
|
|
223
235
|
|
|
224
|
-
// Apply rounding
|
|
236
|
+
// Apply rounding: per-formula config takes precedence, then defaultRounding
|
|
225
237
|
let value = result.value;
|
|
226
|
-
if (
|
|
227
|
-
|
|
238
|
+
if (this.isDecimal(value)) {
|
|
239
|
+
if (formula.rounding) {
|
|
240
|
+
// Per-formula rounding always takes precedence
|
|
241
|
+
value = this.applyRounding(value as Decimal, formula.rounding);
|
|
242
|
+
} else if (applyIntermediateRounding) {
|
|
243
|
+
// Apply default rounding to intermediate values
|
|
244
|
+
value = this.applyRounding(value as Decimal, this.config.defaultRounding!);
|
|
245
|
+
}
|
|
228
246
|
}
|
|
229
247
|
|
|
230
248
|
// Handle errors based on formula config
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -264,6 +264,19 @@ export interface EvaluationResultSet {
|
|
|
264
264
|
evaluationOrder: string[];
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
// ============================================================================
|
|
268
|
+
// Batch Evaluation Options
|
|
269
|
+
// ============================================================================
|
|
270
|
+
|
|
271
|
+
export interface EvaluateAllOptions {
|
|
272
|
+
/**
|
|
273
|
+
* When true, disables automatic intermediate rounding even if defaultRounding
|
|
274
|
+
* is configured in the engine. Per-formula rounding configurations are still applied.
|
|
275
|
+
* @default false
|
|
276
|
+
*/
|
|
277
|
+
disableIntermediateRounding?: boolean;
|
|
278
|
+
}
|
|
279
|
+
|
|
267
280
|
// ============================================================================
|
|
268
281
|
// Validation Result
|
|
269
282
|
// ============================================================================
|