Move safeNumber to shared util and tweak implementation

This commit is contained in:
James Long 2022-08-12 00:06:56 -04:00
parent 4b83552ddf
commit 2d9b319e45
5 changed files with 62 additions and 51 deletions

View file

@ -3,7 +3,7 @@ import * as db from '../db';
import * as prefs from '../prefs'; import * as prefs from '../prefs';
import * as sheet from '../sheet'; import * as sheet from '../sheet';
import { batchMessages } from '../sync'; import { batchMessages } from '../sync';
import { safeNumber } from './util'; import { safeNumber } from '../../shared/util';
async function getSheetValue(sheetName, cell) { async function getSheetValue(sheetName, cell) {
const node = await sheet.getCell(sheetName, cell); const node = await sheet.getCell(sheetName, cell);

View file

@ -1,3 +1,4 @@
import { safeNumber } from '../../shared/util';
import * as sheet from '../sheet'; import * as sheet from '../sheet';
import { number, sumAmounts } from './util'; import { number, sumAmounts } from './util';
@ -25,17 +26,17 @@ export async function createCategory(cat, sheetName, prevSheetName) {
], ],
run: (budgeted, sumAmount, prevCarryover, prevLeftover) => { run: (budgeted, sumAmount, prevCarryover, prevLeftover) => {
if (cat.is_income) { if (cat.is_income) {
return ( return safeNumber(
number(budgeted) - number(budgeted) -
number(sumAmount) + number(sumAmount) +
(prevCarryover ? number(prevLeftover) : 0) (prevCarryover ? number(prevLeftover) : 0)
); );
} }
return ( return safeNumber(
number(budgeted) + number(budgeted) +
number(sumAmount) + number(sumAmount) +
(prevCarryover ? number(prevLeftover) : 0) (prevCarryover ? number(prevLeftover) : 0)
); );
} }
}); });
@ -50,7 +51,7 @@ export async function createCategory(cat, sheetName, prevSheetName) {
refresh: true, refresh: true,
run: (budgeted, sumAmount, carryover) => { run: (budgeted, sumAmount, carryover) => {
return carryover return carryover
? Math.max(0, number(budgeted) + number(sumAmount)) ? Math.max(0, safeNumber(number(budgeted) + number(sumAmount)))
: sumAmount; : sumAmount;
} }
}); });
@ -109,7 +110,7 @@ export function createSummary(groups, categories, sheetName) {
initialValue: 0, initialValue: 0,
dependencies: ['total-income', 'total-spent'], dependencies: ['total-income', 'total-spent'],
run: (income, spent) => { run: (income, spent) => {
return income - -spent; return safeNumber(income - -spent);
} }
}); });
} }

View file

@ -1,6 +1,7 @@
import * as monthUtils from '../../shared/months'; import * as monthUtils from '../../shared/months';
import * as sheet from '../sheet'; import * as sheet from '../sheet';
import { number, sumAmounts, flatten2, unflatten2, safeNumber } from './util'; import { number, sumAmounts, flatten2, unflatten2 } from './util';
import { safeNumber } from './util';
const { resolveName } = require('../spreadsheet/util'); const { resolveName } = require('../spreadsheet/util');
@ -51,10 +52,10 @@ export function createCategory(cat, sheetName, prevSheetName) {
`${prevSheetName}!leftover-pos-${cat.id}` `${prevSheetName}!leftover-pos-${cat.id}`
], ],
run: (budgeted, spent, prevCarryover, prevLeftover, prevLeftoverPos) => { run: (budgeted, spent, prevCarryover, prevLeftover, prevLeftoverPos) => {
return ( return safeNumber(
number(budgeted) + number(budgeted) +
number(spent) + number(spent) +
(prevCarryover ? number(prevLeftover) : number(prevLeftoverPos)) (prevCarryover ? number(prevLeftover) : number(prevLeftoverPos))
); );
} }
}); });
@ -78,7 +79,7 @@ export function createSummary(groups, categories, prevSheetName, sheetName) {
sheet.get().createDynamic(sheetName, 'from-last-month', { sheet.get().createDynamic(sheetName, 'from-last-month', {
initialValue: 0, initialValue: 0,
dependencies: [`${prevSheetName}!to-budget`, `${prevSheetName}!buffered`], dependencies: [`${prevSheetName}!to-budget`, `${prevSheetName}!buffered`],
run: (toBudget, buffered) => number(toBudget) + number(buffered) run: (toBudget, buffered) => safeNumber(number(toBudget) + number(buffered))
}); });
// Alias the group income total to `total-income` // Alias the group income total to `total-income`
@ -91,7 +92,8 @@ export function createSummary(groups, categories, prevSheetName, sheetName) {
sheet.get().createDynamic(sheetName, 'available-funds', { sheet.get().createDynamic(sheetName, 'available-funds', {
initialValue: 0, initialValue: 0,
dependencies: ['total-income', 'from-last-month'], dependencies: ['total-income', 'from-last-month'],
run: (income, fromLastMonth) => number(income) + number(fromLastMonth) run: (income, fromLastMonth) =>
safeNumber(number(income) + number(fromLastMonth))
}); });
sheet.get().createDynamic(sheetName, 'last-month-overspent', { sheet.get().createDynamic(sheetName, 'last-month-overspent', {
@ -104,12 +106,14 @@ export function createSummary(groups, categories, prevSheetName, sheetName) {
), ),
run: (...data) => { run: (...data) => {
data = unflatten2(data); data = unflatten2(data);
return data.reduce((total, [leftover, carryover]) => { return safeNumber(
if (carryover) { data.reduce((total, [leftover, carryover]) => {
return total; if (carryover) {
} return total;
return total + Math.min(0, number(leftover)); }
}, 0); return total + Math.min(0, number(leftover));
}, 0)
);
} }
}); });
@ -135,11 +139,11 @@ export function createSummary(groups, categories, prevSheetName, sheetName) {
'buffered' 'buffered'
], ],
run: (available, lastOverspent, totalBudgeted, buffered) => { run: (available, lastOverspent, totalBudgeted, buffered) => {
return ( return safeNumber(
number(available) + number(available) +
number(lastOverspent) + number(lastOverspent) +
number(totalBudgeted) - number(totalBudgeted) -
number(buffered) number(buffered)
); );
} }
}); });

View file

@ -1,11 +1,14 @@
import { number } from '../spreadsheet/globals'; import { number } from '../spreadsheet/globals';
import { safeNumber } from '../../shared/util';
export { number } from '../spreadsheet/globals'; export { number } from '../spreadsheet/globals';
export function sumAmounts(...amounts) { export function sumAmounts(...amounts) {
return amounts.reduce((total, amount) => { return safeNumber(
return total + number(amount); amounts.reduce((total, amount) => {
}, 0); return total + number(amount);
}, 0)
);
} }
export function flatten2(arr) { export function flatten2(arr) {
@ -19,23 +22,3 @@ export function unflatten2(arr) {
} }
return res; return res;
} }
// Note that we don't restrict values to `Number.MIN_SAFE_INTEGER <= value <= Number.MAX_SAFE_INTEGER`
// where `Number.MAX_SAFE_INTEGER == 2^53 - 1` but a smaller range over `-(2^43-1) <= value <= 2^43 - 1`.
// This ensure that the number is accurate not just for the integer component but for 3 decimal places also.
//
// This gives us the guarantee that can use `safeNumber` on number whether they are unscaled user inputs
// or they have been converted to integers (using `amountToInteger`).
const MAX_SAFE_NUMBER = 2 ** 43 - 1;
const MIN_SAFE_NUMBER = -MAX_SAFE_NUMBER;
export function safeNumber(value) {
value = number(value);
if (value > MAX_SAFE_NUMBER || value < MIN_SAFE_NUMBER) {
throw new Error(
"Can't safely perform arithmetic with number: " + JSON.stringify(value)
);
}
return value;
}

View file

@ -298,6 +298,30 @@ export function getNumberFormat() {
setNumberFormat('comma-dot'); setNumberFormat('comma-dot');
// Number utilities
// We dont use `Number.MAX_SAFE_NUMBER` and such here because those
// numbers are so large that it's not safe to convert them to floats
// (i.e. N / 100). For example, `9007199254740987 / 100 ===
// 90071992547409.88`. While the internal arithemetic would be correct
// because we always do that on numbers, the app would potentially
// display wrong numbers. Instead of `2**53` we use `2**51` which
// gives division more room to be correct
const MAX_SAFE_NUMBER = 2**51 - 1;
const MIN_SAFE_NUMBER = -MAX_SAFE_NUMBER;
export function safeNumber(value) {
if (!Number.isInteger(value)) {
throw new Error('safeNumber: number is not an integer: ' + value);
}
if (value > MAX_SAFE_NUMBER || value < MIN_SAFE_NUMBER) {
throw new Error(
"safeNumber: can't safely perform arithmetic with number: " + value
);
}
return value;
}
export function toRelaxedNumber(value) { export function toRelaxedNumber(value) {
return integerToAmount(currencyToInteger(value) || 0); return integerToAmount(currencyToInteger(value) || 0);
} }
@ -307,8 +331,7 @@ export function toRelaxedInteger(value) {
} }
export function integerToCurrency(n) { export function integerToCurrency(n) {
// Awesome return numberFormat.formatter.format(safeNumber(n) / 100);
return numberFormat.formatter.format(n / 100);
} }
export function amountToCurrency(n) { export function amountToCurrency(n) {
@ -340,7 +363,7 @@ export function amountToInteger(n) {
} }
export function integerToAmount(n) { export function integerToAmount(n) {
return parseFloat((n / 100).toFixed(2)); return parseFloat((safeNumber(n) / 100).toFixed(2));
} }
// This is used when the input format could be anything (from // This is used when the input format could be anything (from