feat: Add BigNumber implementation (#6253)

> This is a proposal - not necessarily the end result - to kick off the discussion about the implementation of the new totals utilities

### What
Introduces a BigNumber class implementation, enabling us to work with high-precision numeric values.

**Scope**
- Introduce the BigNumber class
- Remain somewhat backward-compatible (in behavior)
- Establish a foundation for handling high-precision values in more complex scenarios

**Not in scope**
- The implementation will not address complex use cases. However, the concept introduced now should be open for extensibility, so this can be added later without major changes to the calculation logic

### How
There are significant changes to three areas in this PR:
- Schemas
- (De)-Serialization
- Totals calculations

**Schemas**

Domains that need high-precision values will have two DB columns for each value in the database: a standard numeric column and a raw value column.

The standard column is for basic operations like sorting and filtering in the database and is what should be publicly exposed in our API.

The raw value is initially used solely for precise calculations and is stored as a JSONB column. Keeping it as JSONB is flexible and will allow us to extend the concept in future iterations. As of now, the raw value will only require a single property `value`.

**(De)-Serialization**

We cast the raw JSONB value to a `BigNumberRawValue` when reading from the database. 

We serialize the standard value to a `BigNumber` when reading from the database. 

We use the standard numeric value to construct the raw value upon writing to the database.

For example, the unit price and raw unit price on line items will be inserted as follows:
```ts
@BeforeCreate()
onCreate() {
  this.id = generateEntityId(this.id, "cali")
  
  const asBigNumber = new BigNumber(this.raw_unit_price ?? this.unit_price)
  
  this.unit_price = asBigNumber.numeric
  this.raw_unit_price = asBigNumber.raw
}
```

**Totals calculations**

For totals calculations, we will use the [`bignumber.js`](https://github.com/MikeMcl/bignumber.js/) library. The library ships with a `BigNumber` class with arithmetic methods for precise calculations. 

When we need to perform a calculation, we construct the BigNumber class from the library using the raw value from the database.

Let's have a look at an oversimplified example:
```ts
// create cart with line items
const [createdCart] = await service.create([
  {
    currency_code: "eur",
    items: [
      // li_1234
      {
        title: "test",
        quantity: 2,
        unit_price: 100,
      },
      // li_4321
      {
        title: "test",
        quantity: 3,
        // raw price creation
        unit_price: 200,
      },
    ],
  },
])
```

```ts
// calculating line item totals
import BN from "bignumber.js"

const lineItem1 = await service.retrieveLineItem("li_1234")
const lineItem2 = await service.retrieveLineItem("li_4321")

const bnUnitPrice1 = new BN(lineItem1.unit_price.raw)
const bnUnitPrice2 = new BN(lineItem2.unit_price.raw)

const line1Total = bnUnitPrice1.multipliedBy(lineItem1.quantity)
const line2Total = bnUnitPrice2.multipliedBy(lineItem2.quantity)

const total = line1Total.plus(line2Total)
```

**A note on backward compatibility**
Our BigNumber implementation is built to support the existing behavior of numeric values in the database. So even though we serialize the value to a BigNumber, you will still be able to treat it as a standard number, as we've always done.

For example, the following works perfectly fine:
```ts
const lineItem = await service.createLineItem({
  title: "test",
  quantity: 2,
  unit_price: 100,
})

console.log(lineItem.unit_price) // will print `100`
```

However, the type of `unit_price` will be `number | BigNumber`.
This commit is contained in:
Oli Juhl
2024-02-09 10:56:50 +00:00
committed by GitHub
parent dc88fd3507
commit 94062d28be
24 changed files with 529 additions and 42 deletions
+1
View File
@@ -14,6 +14,7 @@ export * from "./get-iso-string-from-date"
export * from "./get-selects-and-relations-from-object-array"
export * from "./group-by"
export * from "./handle-postgres-database-error"
export * from "./is-big-number"
export * from "./is-date"
export * from "./is-defined"
export * from "./is-email"
@@ -0,0 +1,6 @@
import { BigNumberRawValue } from "@medusajs/types"
import { isObject } from "./is-object"
export function isBigNumber(obj: any): obj is BigNumberRawValue {
return isObject(obj) && "value" in obj
}
+2
View File
@@ -16,5 +16,7 @@ export * from "./product"
export * from "./promotion"
export * from "./search"
export * from "./shipping"
export * from "./totals"
export * from "./totals/big-number"
export const MedusaModuleType = Symbol.for("MedusaModule")
@@ -0,0 +1,36 @@
import { BigNumber as BN } from "bignumber.js"
import { BigNumber } from "../big-number"
describe("BigNumber", function () {
describe("constructor", function () {
it("should set and return number", function () {
const number = new BigNumber(42)
expect(JSON.stringify(number)).toEqual(JSON.stringify(42))
})
it("should set BigNumber and return number", function () {
const number = new BigNumber({
value: "42",
})
expect(JSON.stringify(number)).toEqual(JSON.stringify(42))
})
it("should set string and return number", function () {
const number = new BigNumber("42")
expect(JSON.stringify(number)).toEqual(JSON.stringify(42))
})
it("should set bignumber.js and return number", function () {
const bn = new BN("42")
const number = new BigNumber(bn)
expect(JSON.stringify(number)).toEqual(JSON.stringify(42))
})
it("should throw if not correct type", function () {
// @ts-ignore
expect(() => new BigNumber([])).toThrow(
"Invalid BigNumber value. Should be one of: string, number, BigNumber (bignumber.js), BigNumberRawValue"
)
})
})
})
+97
View File
@@ -0,0 +1,97 @@
import { BigNumberRawPriceInput, BigNumberRawValue } from "@medusajs/types"
import { BigNumber as BigNumberJS } from "bignumber.js"
import { isBigNumber, isString } from "../common"
export class BigNumber {
static DEFAULT_PRECISION = 20
private numeric_: number
private raw_?: BigNumberRawValue
constructor(rawPrice: BigNumberRawPriceInput) {
this.setRawPriceOrThrow(rawPrice)
}
setRawPriceOrThrow(rawPrice: BigNumberRawPriceInput) {
if (BigNumberJS.isBigNumber(rawPrice)) {
/**
* Example:
* const bnUnitPrice = new BigNumberJS("10.99")
* const unitPrice = new BigNumber(bnUnitPrice)
*/
this.numeric_ = rawPrice.toNumber()
this.raw_ = {
value: rawPrice.toPrecision(BigNumber.DEFAULT_PRECISION),
}
} else if (isString(rawPrice)) {
/**
* Example: const unitPrice = "1234.1234"
*/
const bigNum = new BigNumberJS(rawPrice)
this.numeric_ = bigNum.toNumber()
this.raw_ = this.raw_ = {
value: bigNum.toPrecision(BigNumber.DEFAULT_PRECISION),
}
} else if (isBigNumber(rawPrice)) {
/**
* Example: const unitPrice = { value: "1234.1234" }
*/
this.numeric_ = BigNumberJS(rawPrice.value).toNumber()
this.raw_ = {
...rawPrice,
}
} else if (typeof rawPrice === `number` && !Number.isNaN(rawPrice)) {
/**
* Example: const unitPrice = 1234
*/
this.numeric_ = rawPrice as number
this.raw_ = {
value: BigNumberJS(rawPrice as number).toString(),
}
} else {
throw new Error(
"Invalid BigNumber value. Should be one of: string, number, BigNumber (bignumber.js), BigNumberRawValue"
)
}
}
get numeric(): number {
let raw = this.raw_ as BigNumberRawValue
if (raw) {
return new BigNumberJS(raw.value).toNumber()
} else {
return this.numeric_
}
}
set numeric(value: BigNumberRawPriceInput) {
const newValue = new BigNumber(value)
this.numeric_ = newValue.numeric_
this.raw_ = newValue.raw_
}
get raw(): BigNumberRawValue | undefined {
return this.raw_
}
set raw(rawValue: BigNumberRawPriceInput) {
const newValue = new BigNumber(rawValue)
this.numeric_ = newValue.numeric_
this.raw_ = newValue.raw_
}
toJSON() {
return this.raw_
? new BigNumberJS(this.raw_.value).toNumber()
: this.numeric_
}
valueOf() {
return this.raw_
? new BigNumberJS(this.raw_.value).toNumber()
: this.numeric_
}
}
+235
View File
@@ -0,0 +1,235 @@
import {
BigNumberRawValue,
CartDTO,
CartShippingMethodDTO,
} from "@medusajs/types"
import { BigNumber as BigNumberJs } from "bignumber.js"
import { BigNumber } from "./big-number"
import { toBigNumberJs } from "./to-big-number-js"
type GetLineItemTotalsContext = {
includeTax?: boolean
taxRate?: number | null
}
interface GetShippingMethodTotalInput extends CartShippingMethodDTO {
raw_amount: BigNumberRawValue
}
interface GetItemTotalInput {
id: string
unit_price: BigNumber
quantity: number
is_tax_inclusive?: boolean
tax_total?: BigNumber
original_tax_total?: BigNumber
}
interface GetItemTotalOutput {
quantity: number
unit_price: BigNumber
subtotal: BigNumber
total: BigNumber
original_total: BigNumber
discount_total: BigNumber
tax_total: BigNumber
original_tax_total: BigNumber
}
export function getShippingMethodTotals(
shippingMethods: GetShippingMethodTotalInput[],
context: { includeTax?: boolean }
) {
const { includeTax } = context
const shippingMethodsTotals = {}
for (const shippingMethod of shippingMethods) {
shippingMethodsTotals[shippingMethod.id] = getShippingMethodTotals_(
shippingMethod,
{
includeTax,
}
)
}
return shippingMethodsTotals
}
export function getShippingMethodTotals_(
shippingMethod: GetShippingMethodTotalInput,
context: { includeTax?: boolean }
) {
const { amount, taxTotal, originalTaxTotal } = toBigNumberJs(shippingMethod, [
"amount",
"tax_total",
"original_tax_total",
])
const amountBn = new BigNumber(amount)
const totals = {
amount: amountBn,
total: amountBn,
original_total: amountBn,
subtotal: amountBn,
tax_total: new BigNumber(taxTotal),
original_tax_total: new BigNumber(originalTaxTotal),
}
const isTaxInclusive = context.includeTax ?? shippingMethod.is_tax_inclusive
if (isTaxInclusive) {
const subtotal = amount.minus(taxTotal)
totals.subtotal = new BigNumber(subtotal)
} else {
const originalTotal = amount.plus(originalTaxTotal)
const total = amount.plus(taxTotal)
totals.original_total = new BigNumber(originalTotal)
totals.total = new BigNumber(total)
}
return totals
}
export function getLineItemTotals(
items: GetItemTotalInput[],
context: GetLineItemTotalsContext
): { [itemId: string]: GetItemTotalOutput } {
const itemsTotals: { [itemId: string]: GetItemTotalOutput } = {}
for (const item of items) {
itemsTotals[item.id] = getTotalsForSingleLineItem(item, {
includeTax: context.includeTax,
})
}
return itemsTotals
}
function getTotalsForSingleLineItem(
item: GetItemTotalInput,
context: GetLineItemTotalsContext
) {
const { unitPrice, taxTotal, originalTaxTotal } = toBigNumberJs(item, [
"unit_price",
"tax_total",
"original_tax_total",
])
const subtotal = unitPrice.times(item.quantity)
const discountTotal = BigNumberJs(0)
const total = subtotal.minus(discountTotal)
const totals: GetItemTotalOutput = {
quantity: item.quantity,
unit_price: item.unit_price,
subtotal: new BigNumber(subtotal),
total: new BigNumber(total),
original_total: new BigNumber(subtotal),
discount_total: new BigNumber(discountTotal),
tax_total: new BigNumber(taxTotal),
original_tax_total: new BigNumber(originalTaxTotal),
}
const isTaxInclusive = context.includeTax ?? item.is_tax_inclusive
if (isTaxInclusive) {
const subtotal = unitPrice.times(totals.quantity).minus(originalTaxTotal)
const subtotalBn = new BigNumber(subtotal)
totals.subtotal = subtotalBn
totals.total = subtotalBn
totals.original_total = subtotalBn
} else {
const newTotal = total.plus(taxTotal)
const originalTotal = subtotal.plus(originalTaxTotal)
totals.total = new BigNumber(newTotal)
totals.original_total = new BigNumber(originalTotal)
}
return totals
}
export function decorateCartTotals(
{
shippingMethods = [],
items = [],
}: {
items: GetItemTotalInput[]
shippingMethods: GetShippingMethodTotalInput[]
},
totalsConfig: { includeTaxes?: boolean } = {}
): CartDTO {
let cart: any = {}
const includeTax = totalsConfig?.includeTaxes
const itemsTotals = getLineItemTotals(items, {
includeTax,
})
const shippingMethodsTotals = getShippingMethodTotals(shippingMethods, {
includeTax,
})
const subtotal = BigNumberJs(0)
const discountTotal = BigNumberJs(0)
const itemTaxTotal = BigNumberJs(0)
const shippingTotal = BigNumberJs(0)
const shippingTaxTotal = BigNumberJs(0)
cart.items = items.map((item) => {
const itemTotals = Object.assign(item, itemsTotals[item.id] ?? {})
const subtotal = BigNumberJs(itemTotals.subtotal.raw!.value)
const discountTotal = BigNumberJs(itemTotals.discount_total.raw!.value)
const itemTaxTotal = BigNumberJs(itemTotals.tax_total.raw!.value)
subtotal.plus(subtotal)
discountTotal.plus(discountTotal)
itemTaxTotal.plus(itemTaxTotal)
return itemTotals
})
cart.shipping_methods = shippingMethods.map((shippingMethod) => {
const methodTotals = Object.assign(
shippingMethod,
shippingMethodsTotals[shippingMethod.id] ?? {}
)
const total = BigNumberJs(methodTotals.total.raw!.value)
const methodTaxTotal = BigNumberJs(methodTotals.tax_total.raw!.value)
shippingTotal.plus(total)
shippingTaxTotal.plus(methodTaxTotal)
return methodTotals
})
const taxTotal = itemTaxTotal.plus(shippingTaxTotal)
// TODO: Discount + Gift Card calculations
// TODO: subtract (cart.gift_card_total + cart.discount_total + cart.gift_card_tax_total)
const total = subtotal.plus(shippingTotal).plus(shippingTotal).plus(taxTotal)
cart.total = new BigNumber(total)
cart.subtotal = new BigNumber(subtotal)
cart.discount_total = new BigNumber(discountTotal)
cart.item_tax_total = new BigNumber(itemTaxTotal)
cart.shipping_total = new BigNumber(shippingTotal)
cart.shipping_tax_total = new BigNumber(shippingTaxTotal)
cart.tax_total = new BigNumber(taxTotal)
// cart.discount_total = Math.round(cart.discount_total)
// cart.gift_card_total = giftCardTotal.total || 0
// cart.gift_card_tax_total = giftCardTotal.tax_total || 0
return cart as CartDTO
}
@@ -0,0 +1,31 @@
import { BigNumberRawPriceInput } from "@medusajs/types"
import { BigNumber as BigNumberJs } from "bignumber.js"
import { isDefined, toCamelCase } from "../common"
import { BigNumber } from "./big-number"
type InputEntity<T, V extends string> = { [key in V]?: InputEntityField }
type InputEntityField = number | string | BigNumber
type Camelize<V extends string> = V extends `${infer A}_${infer B}`
? `${A}${Camelize<Capitalize<B>>}`
: V
type Output<V extends string> = { [key in Camelize<V>]: BigNumberJs }
export function toBigNumberJs<T, V extends string>(
entity: InputEntity<T, V>,
fields: V[]
): Output<V> {
return fields.reduce((acc, field: string) => {
const camelCased = toCamelCase(field)
let val: BigNumberRawPriceInput = 0
if (isDefined(entity[field])) {
const entityField = entity[field]
val = (entityField?.raw?.value ?? entityField) as number | string
}
acc[camelCased] = new BigNumberJs(val)
return acc
}, {} as Output<V>)
}