# jg-bignumber recipes

Patterns for apps that add up money and report how a metric changed, with exact decimal math and rounding where you choose.

## Summing money without rounding errors

Use this for a cart, an invoice, or any total of prices. JavaScript numbers are binary fractions, so `0.1 + 0.2` is `0.30000000000000004`, and each addition can add an error. Keep amounts as decimal strings from the API or the database, do the arithmetic with jg-bignumber, and round where the invoice rounds:

```ts
import {
  calculateSum,
  getBigNumber,
  prettifyValue,
  processValue,
  type IBigNumber,
} from 'jg-bignumber';

// a line of an invoice, with its price as a decimal string, as an API or a PostgreSQL numeric
// column sends it, so it never passes through a float
type IInvoiceLine = { unitPrice: string; quantity: number };

type IInvoiceTotals = { subtotal: IBigNumber; tax: IBigNumber; total: IBigNumber };

// the reader's invoice, with its tax rate as a percentage, such as '8.25'
declare const invoice: { lines: IInvoiceLine[]; taxRate: string };

// the cell that shows the invoice's total
declare const totalCell: HTMLElement;

const calculateInvoiceTotals = (lines: IInvoiceLine[], taxRate: string): IInvoiceTotals => {
  // each line's total stays exact, so nothing is rounded before the sum
  const lineTotals = lines.map(({ unitPrice, quantity }) =>
    getBigNumber(unitPrice).times(quantity),
  );

  // the subtotal and the tax are each rounded to cents, as the invoice prints them, so the
  // printed amounts add up to the printed total
  const subtotal = calculateSum(lineTotals, { type: 'bignumber' });
  const tax = processValue(subtotal.times(taxRate).dividedBy(100), { type: 'bignumber' });
  return { subtotal, tax, total: calculateSum([subtotal, tax], { type: 'bignumber' }) };
};

const { total } = calculateInvoiceTotals(invoice.lines, invoice.taxRate);

// toFixed keeps the cents, such as '1234.50', where a number drops the trailing zero, and
// minimumDecimalPlaces does the same for the formatted total, such as '$1,234.50'
const totalForApi = total.toFixed(2);
totalCell.textContent = prettifyValue(total, { format: { prefix: '$', minimumDecimalPlaces: 2 } });
```

Every jg-bignumber calculation, and `processValue`, rounds its result, so call one where the invoice rounds, and do the steps in between with BigNumber methods, such as `times` and `dividedBy`, which don't. `{ type: 'bignumber' }` returns a BigNumber instead of a number, so the next step starts from the decimal value. Results round to 2 decimal places with `ROUND_HALF_UP`: pass `roundingMode: 'ROUND_HALF_EVEN'` where an accounting rule asks for banker's rounding, or `decimalPlaces: 0` for a currency without cents. A string that isn't a number, such as `''` from an empty form field, throws `INVALID_VALUE`, so check input with `isNumber` before it reaches the math.

## Storing money as integers

Use this when amounts are stored or added up as integers, such as microcents in a PostgreSQL `BIGINT`, which sums exactly and sorts fast. Convert at the edges: an amount that reaches the API becomes an integer before it's stored, and an integer read back becomes a decimal string before it's shown. The conversions never round, so an amount with more decimals than the unit holds is rejected instead of losing a fraction of a cent:

```ts
import { fromScaledInteger, prettifyValue, toScaledInteger } from 'jg-bignumber';

// microcents: a dollar is 100,000,000 of them
const MICROCENTS = 8;

// adds a credit to an account's balance, such as with a SQL query on a BIGINT column, which
// node-postgres returns as a string
declare function addToBalance(accountId: string, microcents: bigint): Promise<string>;

export const creditAccount = async (accountId: string, dollars: string): Promise<string> => {
  // throws INVALID_VALUE for '1e-8', '0x10', or '0.123456789', so the request fails instead of
  // storing a different amount
  const balance = await addToBalance(accountId, toScaledInteger(dollars, MICROCENTS));

  // the exact amount, such as '12.50000000', formatted as '$12.50' for people
  return prettifyValue(fromScaledInteger(balance, MICROCENTS), {
    format: { prefix: '$', minimumDecimalPlaces: 2 },
  });
};
```

Pass amounts as strings, as a form or an API sends them. A number works while JavaScript writes it as a plain decimal, but `0.00000001` is written `1e-8`, and `0.1 + 0.2` is `0.30000000000000004`, so both are rejected. `fromScaledInteger` keeps every decimal, so its result can go back through `toScaledInteger` unchanged, and `prettifyValue` rounds it only for display.

## A percentage change for a dashboard

Use this for a metric tile that compares a period with the one before, such as this month's revenue against last month's. `calculatePercentageChange` returns a signed percentage, and `prettifyValue` formats it with a sign and a suffix:

```ts
import {
  calculatePercentageChange,
  getBigNumber,
  prettifyValue,
  type IBigNumberValue,
} from 'jg-bignumber';

// how a metric moved, which picks the tile's arrow and color
type ITrend = 'up' | 'down' | 'flat';

type IChange = { label: string; trend: ITrend };

// the reader's revenue for the last two months, and the tile's change label
declare const revenue: { previous: string; current: string };
declare const changeLabel: HTMLElement;

// describes how a metric changed, or returns null when the previous value can't be the base
const describeChange = (previous: IBigNumberValue, current: IBigNumberValue): IChange | null => {
  // a change from 0, or from a negative value, has no meaningful percentage, and
  // calculatePercentageChange throws NEGATIVE_VALUE_NOT_ALLOWED for one
  if (!getBigNumber(previous).isGreaterThan(0)) {
    return null;
  }

  // rounded to one decimal before the trend is picked, so the arrow always agrees with the label
  const change = calculatePercentageChange(previous, current, { decimalPlaces: 1 });
  let trend: ITrend = 'flat';
  if (change > 0) {
    trend = 'up';
  } else if (change < 0) {
    trend = 'down';
  }

  // only an increase gets a plus sign, so an unchanged metric reads 0%, not +0%
  const label = prettifyValue(change, {
    format: { positiveSign: trend === 'up' ? '+' : '', suffix: '%' },
  });
  return { label, trend };
};

const change = describeChange(revenue.previous, revenue.current);

// revenue that starts from 0 has no percentage, so the tile says it's new
changeLabel.textContent = change?.label ?? 'New';
changeLabel.dataset['trend'] = change?.trend ?? 'flat';
```

A change too small to show, such as 0.04%, rounds to 0 and reads as flat, never as an arrow next to 0%. The largest decrease is -100%, which `calculatePercentageChange` returns whenever the current value is 0 or less. `prettifyValue` groups thousands, as in `+1,234.6%`, and drops trailing zeros, so 12% and 12.5% can sit side by side.
