@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@the-trybe/formula-engine",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Configuration-driven expression evaluation system with dependency resolution and decimal precision",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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
+ });
@@ -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 if configured
236
+ // Apply rounding: per-formula config takes precedence, then defaultRounding
225
237
  let value = result.value;
226
- if (formula.rounding && this.isDecimal(value)) {
227
- value = this.applyRounding(value as Decimal, formula.rounding);
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
@@ -19,6 +19,7 @@ export {
19
19
  // Results
20
20
  EvaluationResult,
21
21
  EvaluationResultSet,
22
+ EvaluateAllOptions,
22
23
  ValidationResult,
23
24
  CacheStats,
24
25
 
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
  // ============================================================================