From 94062d28be57289ee30f5f0c8896802e78c384ae Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Fri, 9 Feb 2024 11:56:50 +0100 Subject: [PATCH] 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`. --- .../services/cart-module/index.spec.ts | 8 +- .../CartModuleSetup20240122122952.ts | 3 + packages/cart/src/models/line-item.ts | 38 ++- packages/cart/src/models/shipping-method.ts | 29 ++- packages/cart/src/services/cart-module.ts | 4 +- packages/cart/src/services/index.ts | 2 +- packages/cart/src/types/line-item.ts | 2 +- packages/medusa/src/services/new-totals.ts | 10 +- packages/payment/src/models/capture.ts | 3 +- packages/types/package.json | 1 + packages/types/src/cart/common.ts | 36 +-- packages/types/src/cart/mutations.ts | 2 +- packages/types/src/index.ts | 1 + packages/types/src/totals/big-number.ts | 12 + packages/types/src/totals/index.ts | 2 + packages/utils/package.json | 1 + packages/utils/src/common/index.ts | 1 + packages/utils/src/common/is-big-number.ts | 6 + packages/utils/src/index.ts | 2 + .../src/totals/__tests__/big-number.spec.ts | 36 +++ packages/utils/src/totals/big-number.ts | 97 ++++++++ packages/utils/src/totals/index.ts | 235 ++++++++++++++++++ packages/utils/src/totals/to-big-number-js.ts | 31 +++ yarn.lock | 9 + 24 files changed, 529 insertions(+), 42 deletions(-) create mode 100644 packages/types/src/totals/big-number.ts create mode 100644 packages/types/src/totals/index.ts create mode 100644 packages/utils/src/common/is-big-number.ts create mode 100644 packages/utils/src/totals/__tests__/big-number.spec.ts create mode 100644 packages/utils/src/totals/big-number.ts create mode 100644 packages/utils/src/totals/index.ts create mode 100644 packages/utils/src/totals/to-big-number-js.ts diff --git a/packages/cart/integration-tests/__tests__/services/cart-module/index.spec.ts b/packages/cart/integration-tests/__tests__/services/cart-module/index.spec.ts index ac66ef2d20eb7..3148d14b2d597 100644 --- a/packages/cart/integration-tests/__tests__/services/cart-module/index.spec.ts +++ b/packages/cart/integration-tests/__tests__/services/cart-module/index.spec.ts @@ -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" ) }) @@ -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" ) }) }) diff --git a/packages/cart/src/migrations/CartModuleSetup20240122122952.ts b/packages/cart/src/migrations/CartModuleSetup20240122122952.ts index cea797ef6881d..c178fb50c10a8 100644 --- a/packages/cart/src/migrations/CartModuleSetup20240122122952.ts +++ b/packages/cart/src/migrations/CartModuleSetup20240122122952.ts @@ -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"), @@ -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, diff --git a/packages/cart/src/models/line-item.ts b/packages/cart/src/models/line-item.ts index 5ab63a65651a4..2cc4dc87acdce 100644 --- a/packages/cart/src/models/line-item.ts +++ b/packages/cart/src/models/line-item.ts @@ -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, @@ -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], @@ -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! } } diff --git a/packages/cart/src/models/shipping-method.ts b/packages/cart/src/models/shipping-method.ts index c3a2ed448a421..9daa2a1580c8f 100644 --- a/packages/cart/src/models/shipping-method.ts +++ b/packages/cart/src/models/shipping-method.ts @@ -1,4 +1,5 @@ -import { generateEntityId } from "@medusajs/utils" +import { BigNumberRawValue } from "@medusajs/types" +import { BigNumber, generateEntityId } from "@medusajs/utils" import { BeforeCreate, Cascade, @@ -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" @@ -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 @@ -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! } } diff --git a/packages/cart/src/services/cart-module.ts b/packages/cart/src/services/cart-module.ts index 79274eed6b21d..5d404950cbbd1 100644 --- a/packages/cart/src/services/cart-module.ts +++ b/packages/cart/src/services/cart-module.ts @@ -11,11 +11,11 @@ import { import { InjectManager, InjectTransactionManager, - isObject, - isString, MedusaContext, MedusaError, ModulesSdkUtils, + isObject, + isString, } from "@medusajs/utils" import { Address, diff --git a/packages/cart/src/services/index.ts b/packages/cart/src/services/index.ts index 2ed2053ffcb36..440fec6e50ba9 100644 --- a/packages/cart/src/services/index.ts +++ b/packages/cart/src/services/index.ts @@ -1 +1 @@ -export { default as CartModuleService } from "./cart-module" +export { default as CartModuleService } from "./cart-module"; diff --git a/packages/cart/src/types/line-item.ts b/packages/cart/src/types/line-item.ts index b7fd239713a72..caf407251057e 100644 --- a/packages/cart/src/types/line-item.ts +++ b/packages/cart/src/types/line-item.ts @@ -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 } diff --git a/packages/medusa/src/services/new-totals.ts b/packages/medusa/src/services/new-totals.ts index b81215a6eb900..9df49230b0cc7 100644 --- a/packages/medusa/src/services/new-totals.ts +++ b/packages/medusa/src/services/new-totals.ts @@ -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[], @@ -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, diff --git a/packages/payment/src/models/capture.ts b/packages/payment/src/models/capture.ts index f0d5be2a25cf8..643cc01acf918 100644 --- a/packages/payment/src/models/capture.ts +++ b/packages/payment/src/models/capture.ts @@ -1,3 +1,4 @@ +import { generateEntityId } from "@medusajs/utils" import { BeforeCreate, Entity, @@ -7,8 +8,6 @@ import { PrimaryKey, Property, } from "@mikro-orm/core" - -import { generateEntityId } from "@medusajs/utils" import Payment from "./payment" type OptionalCaptureProps = "created_at" diff --git a/packages/types/package.json b/packages/types/package.json index 10d22d6f62395..3e342990f9486 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -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", diff --git a/packages/types/src/cart/common.ts b/packages/types/src/cart/common.ts index b83c2b7d6d514..2fb47b3fe2417 100644 --- a/packages/types/src/cart/common.ts +++ b/packages/types/src/cart/common.ts @@ -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. */ @@ -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 { @@ -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 diff --git a/packages/types/src/cart/mutations.ts b/packages/types/src/cart/mutations.ts index 25d0dc71728d1..dc4ea41016531 100644 --- a/packages/types/src/cart/mutations.ts +++ b/packages/types/src/cart/mutations.ts @@ -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[] diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 5d7d823b6e9e7..ab086b109dab3 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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" diff --git a/packages/types/src/totals/big-number.ts b/packages/types/src/totals/big-number.ts new file mode 100644 index 0000000000000..1a0740d154577 --- /dev/null +++ b/packages/types/src/totals/big-number.ts @@ -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 diff --git a/packages/types/src/totals/index.ts b/packages/types/src/totals/index.ts new file mode 100644 index 0000000000000..0ed2463b75015 --- /dev/null +++ b/packages/types/src/totals/index.ts @@ -0,0 +1,2 @@ +export * from "./big-number"; + diff --git a/packages/utils/package.json b/packages/utils/package.json index 8ffbf885ea8a6..9d9fcbdbe8532 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -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" }, diff --git a/packages/utils/src/common/index.ts b/packages/utils/src/common/index.ts index 2c8a76c74eac8..85e47879eefa7 100644 --- a/packages/utils/src/common/index.ts +++ b/packages/utils/src/common/index.ts @@ -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" diff --git a/packages/utils/src/common/is-big-number.ts b/packages/utils/src/common/is-big-number.ts new file mode 100644 index 0000000000000..8335b649ed93c --- /dev/null +++ b/packages/utils/src/common/is-big-number.ts @@ -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 +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 814ab3a098775..bf8953ef387d6 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -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") diff --git a/packages/utils/src/totals/__tests__/big-number.spec.ts b/packages/utils/src/totals/__tests__/big-number.spec.ts new file mode 100644 index 0000000000000..7fd7702f4d040 --- /dev/null +++ b/packages/utils/src/totals/__tests__/big-number.spec.ts @@ -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" + ) + }) + }) +}) diff --git a/packages/utils/src/totals/big-number.ts b/packages/utils/src/totals/big-number.ts new file mode 100644 index 0000000000000..623d967eff1cd --- /dev/null +++ b/packages/utils/src/totals/big-number.ts @@ -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_ + } +} diff --git a/packages/utils/src/totals/index.ts b/packages/utils/src/totals/index.ts new file mode 100644 index 0000000000000..260851b0b2aa5 --- /dev/null +++ b/packages/utils/src/totals/index.ts @@ -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 +} diff --git a/packages/utils/src/totals/to-big-number-js.ts b/packages/utils/src/totals/to-big-number-js.ts new file mode 100644 index 0000000000000..15c2721383e2f --- /dev/null +++ b/packages/utils/src/totals/to-big-number-js.ts @@ -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 = { [key in V]?: InputEntityField } +type InputEntityField = number | string | BigNumber + +type Camelize = V extends `${infer A}_${infer B}` + ? `${A}${Camelize>}` + : V + +type Output = { [key in Camelize]: BigNumberJs } + +export function toBigNumberJs( + entity: InputEntity, + fields: V[] +): Output { + 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) +} diff --git a/yarn.lock b/yarn.lock index 2203412df7888..698f1efd27f3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8707,6 +8707,7 @@ __metadata: resolution: "@medusajs/types@workspace:packages/types" dependencies: awilix: ^8.0.0 + bignumber.js: ^9.1.2 cross-env: ^5.2.1 ioredis: ^5.2.5 rimraf: ^5.0.1 @@ -8842,6 +8843,7 @@ __metadata: "@mikro-orm/postgresql": 5.9.7 "@types/express": ^4.17.17 awilix: ^8.0.1 + bignumber.js: ^9.1.2 cross-env: ^5.2.1 express: ^4.18.2 jest: ^29.6.3 @@ -21419,6 +21421,13 @@ __metadata: languageName: node linkType: hard +"bignumber.js@npm:^9.1.2": + version: 9.1.2 + resolution: "bignumber.js@npm:9.1.2" + checksum: e17786545433f3110b868725c449fa9625366a6e675cd70eb39b60938d6adbd0158cb4b3ad4f306ce817165d37e63f4aa3098ba4110db1d9a3b9f66abfbaf10d + languageName: node + linkType: hard + "binary-extensions@npm:^1.0.0": version: 1.13.1 resolution: "binary-extensions@npm:1.13.1"