Skip to content

Commit

Permalink
feat: Add BigNumber implementation (#6253)
Browse files Browse the repository at this point in the history
> 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`.
  • Loading branch information
olivermrbl authored Feb 9, 2024
1 parent dc88fd3 commit 94062d2
Show file tree
Hide file tree
Showing 24 changed files with 529 additions and 42 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -481,14 +481,14 @@ describe("Cart Module Service", () => {
const error = await service
.addLineItems(createdCart.id, [
{
quantity: 1,
unit_price: 10,
title: "test",
},
] as any)
.catch((e) => e)

expect(error.message).toContain(
"Value for LineItem.unit_price is required, 'undefined' found"
"Value for LineItem.quantity is required, 'undefined' found"
)
})

Expand All @@ -503,14 +503,14 @@ describe("Cart Module Service", () => {
.addLineItems([
{
cart_id: createdCart.id,
quantity: 1,
unit_price: 10,
title: "test",
},
] as any)
.catch((e) => e)

expect(error.message).toContain(
"Value for LineItem.unit_price is required, 'undefined' found"
"Value for LineItem.quantity is required, 'undefined' found"
)
})
})
Expand Down
3 changes: 3 additions & 0 deletions packages/cart/src/migrations/CartModuleSetup20240122122952.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ export class CartModuleSetup20240122122952 extends Migration {
"is_discountable" BOOLEAN NOT NULL DEFAULT TRUE,
"is_tax_inclusive" BOOLEAN NOT NULL DEFAULT FALSE,
"compare_at_unit_price" NUMERIC NULL,
"raw_compare_at_unit_price" JSONB NULL,
"unit_price" NUMERIC NOT NULL,
"raw_unit_price" JSONB NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT "cart_line_item_pkey" PRIMARY KEY ("id"),
Expand Down Expand Up @@ -134,6 +136,7 @@ export class CartModuleSetup20240122122952 extends Migration {
"name" TEXT NOT NULL,
"description" JSONB NULL,
"amount" NUMERIC NOT NULL,
"raw_amount" JSONB NOT NULL,
"is_tax_inclusive" BOOLEAN NOT NULL DEFAULT FALSE,
"shipping_option_id" TEXT NULL,
"data" JSONB NULL,
Expand Down
38 changes: 30 additions & 8 deletions packages/cart/src/models/line-item.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { DAL } from "@medusajs/types"
import { generateEntityId } from "@medusajs/utils"
import { BigNumberRawValue, DAL } from "@medusajs/types"
import { BigNumber, generateEntityId } from "@medusajs/utils"
import {
BeforeCreate,
BeforeUpdate,
Cascade,
Check,
Collection,
Entity,
ManyToOne,
Expand Down Expand Up @@ -109,12 +109,16 @@ export default class LineItem {
is_tax_inclusive = false

@Property({ columnType: "numeric", nullable: true })
compare_at_unit_price: number | null = null
compare_at_unit_price?: BigNumber | number | null = null

// TODO: Rework when BigNumber has been introduced
@Property({ columnType: "numeric", serializer: Number })
@Check({ expression: "unit_price >= 0" }) // TODO: Validate that numeric types work with the expression
unit_price: number
@Property({ columnType: "jsonb", nullable: true })
raw_compare_at_unit_price: BigNumberRawValue | null = null

@Property({ columnType: "numeric" })
unit_price: BigNumber | number

@Property({ columnType: "jsonb" })
raw_unit_price: BigNumberRawValue

@OneToMany(() => LineItemTaxLine, (taxLine) => taxLine.item, {
cascade: [Cascade.REMOVE],
Expand Down Expand Up @@ -144,10 +148,28 @@ export default class LineItem {
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "cali")

const val = new BigNumber(this.raw_unit_price ?? this.unit_price)

this.unit_price = val.numeric
this.raw_unit_price = val.raw!
}

@BeforeUpdate()
onUpdate() {
const val = new BigNumber(this.raw_unit_price ?? this.unit_price)

this.unit_price = val.numeric
this.raw_unit_price = val.raw as BigNumberRawValue
}

@OnInit()
onInit() {
this.id = generateEntityId(this.id, "cali")

const val = new BigNumber(this.raw_unit_price ?? this.unit_price)

this.unit_price = val.numeric
this.raw_unit_price = val.raw!
}
}
29 changes: 26 additions & 3 deletions packages/cart/src/models/shipping-method.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { generateEntityId } from "@medusajs/utils"
import { BigNumberRawValue } from "@medusajs/types"
import { BigNumber, generateEntityId } from "@medusajs/utils"
import {
BeforeCreate,
Cascade,
Expand All @@ -11,6 +12,7 @@ import {
PrimaryKey,
Property,
} from "@mikro-orm/core"
import { BeforeUpdate } from "typeorm"
import Cart from "./cart"
import ShippingMethodAdjustment from "./shipping-method-adjustment"
import ShippingMethodTaxLine from "./shipping-method-tax-line"
Expand All @@ -37,8 +39,11 @@ export default class ShippingMethod {
@Property({ columnType: "jsonb", nullable: true })
description: string | null = null

@Property({ columnType: "numeric", serializer: Number })
amount: number
@Property({ columnType: "numeric" })
amount: BigNumber | number

@Property({ columnType: "jsonb" })
raw_amount: BigNumberRawValue

@Property({ columnType: "boolean" })
is_tax_inclusive = false
Expand Down Expand Up @@ -92,10 +97,28 @@ export default class ShippingMethod {
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "casm")

const val = new BigNumber(this.raw_amount ?? this.amount)

this.amount = val.numeric
this.raw_amount = val.raw!
}

@BeforeUpdate()
onUpdate() {
const val = new BigNumber(this.raw_amount ?? this.amount)

this.amount = val.numeric
this.raw_amount = val.raw as BigNumberRawValue
}

@OnInit()
onInit() {
this.id = generateEntityId(this.id, "casm")

const val = new BigNumber(this.raw_amount ?? this.amount)

this.amount = val.numeric
this.raw_amount = val.raw!
}
}
4 changes: 2 additions & 2 deletions packages/cart/src/services/cart-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import {
import {
InjectManager,
InjectTransactionManager,
isObject,
isString,
MedusaContext,
MedusaError,
ModulesSdkUtils,
isObject,
isString,
} from "@medusajs/utils"
import {
Address,
Expand Down
2 changes: 1 addition & 1 deletion packages/cart/src/services/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { default as CartModuleService } from "./cart-module"
export { default as CartModuleService } from "./cart-module";
2 changes: 1 addition & 1 deletion packages/cart/src/types/line-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ interface PartialUpsertLineItemDTO {
export interface CreateLineItemDTO extends PartialUpsertLineItemDTO {
title: string
quantity: number
unit_price: number
unit_price: number | string
cart_id: string
}

Expand Down
10 changes: 5 additions & 5 deletions packages/medusa/src/services/new-totals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ export default class NewTotalsService extends TransactionBaseService {

/**
* Calculate and return the items totals for either the legacy calculation or the new calculation
* @param items
* @param param1
* @param items
* @param param1
*/
async getLineItemTotals(
items: LineItem | LineItem[],
Expand Down Expand Up @@ -136,9 +136,9 @@ export default class NewTotalsService extends TransactionBaseService {

/**
* Calculate and return the totals for an item
* @param item
* @param param1
* @returns
* @param item
* @param param1
* @returns
*/
protected async getLineItemTotals_(
item: LineItem,
Expand Down
3 changes: 1 addition & 2 deletions packages/payment/src/models/capture.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { generateEntityId } from "@medusajs/utils"
import {
BeforeCreate,
Entity,
Expand All @@ -7,8 +8,6 @@ import {
PrimaryKey,
Property,
} from "@mikro-orm/core"

import { generateEntityId } from "@medusajs/utils"
import Payment from "./payment"

type OptionalCaptureProps = "created_at"
Expand Down
1 change: 1 addition & 0 deletions packages/types/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"license": "MIT",
"devDependencies": {
"awilix": "^8.0.0",
"bignumber.js": "^9.1.2",
"cross-env": "^5.2.1",
"ioredis": "^5.2.5",
"rimraf": "^5.0.1",
Expand Down
36 changes: 21 additions & 15 deletions packages/types/src/cart/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,23 @@ export interface CartShippingMethodDTO {
discount_tax_total: number
}

export interface CartLineItemDTO {
export interface CartLineItemTotalsDTO {
original_total: number
original_subtotal: number
original_tax_total: number

item_total: number
item_subtotal: number
item_tax_total: number

total: number
subtotal: number
tax_total: number
discount_total: number
discount_tax_total: number
}

export interface CartLineItemDTO extends CartLineItemTotalsDTO {
/**
* The ID of the line item.
*/
Expand Down Expand Up @@ -384,20 +400,6 @@ export interface CartLineItemDTO {
* When the line item was updated.
*/
updated_at?: Date

original_total: number
original_subtotal: number
original_tax_total: number

item_total: number
item_subtotal: number
item_tax_total: number

total: number
subtotal: number
tax_total: number
discount_total: number
discount_tax_total: number
}

export interface CartDTO {
Expand Down Expand Up @@ -478,8 +480,12 @@ export interface CartDTO {
subtotal: number
tax_total: number
discount_total: number
raw_discount_total: any
discount_tax_total: number

gift_card_total: number
gift_card_tax_total: number

shipping_total: number
shipping_subtotal: number
shipping_tax_total: number
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/cart/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export interface CreateLineItemDTO {
is_tax_inclusive?: boolean

compare_at_unit_price?: number
unit_price: number
unit_price: number | string

tax_lines?: CreateTaxLineDTO[]
adjustments?: CreateAdjustmentDTO[]
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export * from "./sales-channel"
export * from "./search"
export * from "./shared-context"
export * from "./stock-location"
export * from "./totals"
export * from "./transaction-base"
export * from "./user"
export * from "./workflow"
Expand Down
12 changes: 12 additions & 0 deletions packages/types/src/totals/big-number.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import BigNumber from "bignumber.js"

export type BigNumberRawValue = {
value: string | number
[key: string]: unknown
}

export type BigNumberRawPriceInput =
| BigNumberRawValue
| number
| string
| BigNumber
2 changes: 2 additions & 0 deletions packages/types/src/totals/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./big-number";

1 change: 1 addition & 0 deletions packages/utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@mikro-orm/migrations": "5.9.7",
"@mikro-orm/postgresql": "5.9.7",
"awilix": "^8.0.1",
"bignumber.js": "^9.1.2",
"knex": "2.4.2",
"ulid": "^2.3.0"
},
Expand Down
1 change: 1 addition & 0 deletions packages/utils/src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 6 additions & 0 deletions packages/utils/src/common/is-big-number.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading

0 comments on commit 94062d2

Please sign in to comment.