feat(medusa): Separate money amount and variant (#4906)

* initial changes

* working test

* final changes to product tests

* update integration tests

* update price list integration tests

* update integration tests

* update unit tests

* update plugin integration tests

* remove catch from integration test

* undo change

* add andWhere

* update upsertCurrencyMoneyAmount method

* undo line item changes

* undo changes

* update deprecated method

* Update packages/medusa/src/migrations/1692953518123-drop_money_amount_constraints_for_pricing_module.ts

Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>

* rename joinTable

* update with joinTable entity

* update load methods

* remove await create

* re-add context test

* update price list behavior for prices

* update price list snapshots

* re-add admin seeder

* pr feedback

* fix unit tests

* fix plugin integration tests

* initial review changes

* redo changes to variant creation

---------

Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>
Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2023-09-13 13:26:20 +02:00
1 parent 3d68be2b6b
commit 5d10c46bb1
52 files changed
+2465 -910

No files matched your search

@@ -1,20 +1,22 @@
import { FlagRouter } from "@medusajs/utils"
import { Router } from "express"
import "reflect-metadata"
import { PriceList, Product } from "../../../.."
import TaxInclusivePricingFeatureFlag from "../../../../loaders/feature-flags/tax-inclusive-pricing"
import { DeleteResponse, PaginatedResponse } from "../../../../types/common"
import middlewares, {
transformBody,
transformQuery,
} from "../../../middlewares"
import { PriceList, Product } from "../../../.."
import {
defaultAdminProductFields,
defaultAdminProductRelations,
} from "../products"
import { AdminPostPriceListsPriceListReq } from "./create-price-list"
import { AdminGetPriceListsPriceListProductsParams } from "./list-price-list-products"
import middlewares, {
transformBody,
transformQuery,
} from "../../../middlewares"
import { AdminGetPriceListPaginationParams } from "./list-price-lists"
import { AdminGetPriceListsPriceListProductsParams } from "./list-price-list-products"
import { AdminPostPriceListsPriceListReq } from "./create-price-list"
import { FlagRouter } from "@medusajs/utils"
import { Router } from "express"
import TaxInclusivePricingFeatureFlag from "../../../../loaders/feature-flags/tax-inclusive-pricing"
const route = Router()
@@ -91,7 +93,11 @@ export const defaultAdminPriceListFields = [
"deleted_at",
]
export const defaultAdminPriceListRelations = ["prices", "customer_groups"]
export const defaultAdminPriceListRelations = [
"prices",
"prices.variants",
"customer_groups",
]
/**
* @schema AdminPriceListRes
@@ -285,4 +291,3 @@ export * from "./get-price-list"
export * from "./list-price-list-products"
export * from "./list-price-lists"
export * from "./update-price-list"
@@ -10,6 +10,7 @@ import {
IsString,
ValidateNested,
} from "class-validator"
import { MedusaError, isDefined } from "medusa-core-utils"
import { FilterableProductProps } from "../../../../types/product"
import PriceListService from "../../../../services/price-list"
@@ -17,7 +18,6 @@ import { ProductStatus } from "../../../../models"
import { Request } from "express"
import { Type } from "class-transformer"
import { pickBy } from "lodash"
import { isDefined } from "medusa-core-utils"
/**
* @oas [get] /admin/price-lists/{id}/products
@@ -407,4 +407,3 @@ export * from "./set-metadata"
export * from "./update-option"
export * from "./update-product"
export * from "./update-variant"
@@ -6,11 +6,11 @@ import {
SalesChannelService,
} from "../../../../services"
import { IInventoryService } from "@medusajs/types"
import { Type } from "class-transformer"
import { Product } from "../../../../models"
import { PricedProduct } from "../../../../types/pricing"
import { FilterableProductProps } from "../../../../types/product"
import { IInventoryService } from "@medusajs/types"
import { PricedProduct } from "../../../../types/pricing"
import { Product } from "../../../../models"
import { Type } from "class-transformer"
/**
* @oas [get] /admin/products
@@ -5,14 +5,14 @@ export class lineItemTaxAdjustmentOnCascadeDelete1680857773272
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "line_item_tax_line" DROP CONSTRAINT "FK_5077fa54b0d037e984385dfe8ad"`
`ALTER TABLE "line_item_tax_line" DROP CONSTRAINT IF EXISTS "FK_5077fa54b0d037e984385dfe8ad"`
)
await queryRunner.query(
`ALTER TABLE "line_item_tax_line" ADD CONSTRAINT "FK_5077fa54b0d037e984385dfe8ad" FOREIGN KEY ("item_id") REFERENCES "line_item"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
)
await queryRunner.query(
`ALTER TABLE "line_item_adjustment" DROP CONSTRAINT "FK_be9aea2ccf3567007b6227da4d2"`
`ALTER TABLE "line_item_adjustment" DROP CONSTRAINT IF EXISTS "FK_be9aea2ccf3567007b6227da4d2"`
)
await queryRunner.query(
`ALTER TABLE "line_item_adjustment" ADD CONSTRAINT "FK_be9aea2ccf3567007b6227da4d2" FOREIGN KEY ("item_id") REFERENCES "line_item"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
@@ -32,14 +32,14 @@ export class addTableProductShippingProfile1680857773273
DROP INDEX IF EXISTS "idx_product_shipping_profile_product_id";
DROP INDEX IF EXISTS "idx_product_shipping_profile_profile_id";
DROP TABLE IF EXISTS "product_shipping_profile";
ALTER TABLE "product" ADD COLUMN IF NOT EXISTS "profile_id";
UPDATE "product" SET "profile_id" = "product_shipping_profile"."profile_id"
FROM "product_shipping_profile"
WHERE "product"."id" = "product_shipping_profile"."product_id";
DROP TABLE IF EXISTS "product_shipping_profile";
ALTER TABLE "product" ALTER COLUMN profile_id SET NOT NULL;
CREATE INDEX IF NOT EXISTS "IDX_80823b7ae866dc5acae2dac6d2" ON "product" ("profile_id");
@@ -0,0 +1,50 @@
import { MigrationInterface, QueryRunner } from "typeorm"
export class dropMoneyAmountConstraintsForPricingModule1692953518123
implements MigrationInterface
{
name = "dropMoneyAmountConstraintsForPricingModule1692953518123"
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`
CREATE TABLE IF NOT EXISTS "product_variant_money_amount"
(
"id" character varying NOT NULL,
"money_amount_id" text NOT NULL,
"variant_id" text NOT NULL,
"deleted_at" TIMESTAMP WITH TIME ZONE,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_product_variant_money_amount" PRIMARY KEY ("id")
);
INSERT INTO "product_variant_money_amount"("id", "money_amount_id", "variant_id")
SELECT CONCAT('pvma_', id), "id", "variant_id" FROM "money_amount";
ALTER TABLE "money_amount" DROP COLUMN IF EXISTS "variant_id";
CREATE UNIQUE INDEX IF NOT EXISTS "idx_product_variant_money_amount_money_amount_id_unique" ON "product_variant_money_amount" ("money_amount_id");
CREATE INDEX IF NOT EXISTS "idx_product_variant_money_amount_variant_id" ON "product_variant_money_amount" ("variant_id");
`
)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`
DROP INDEX IF EXISTS "idx_product_variant_money_amount_money_amount_id_unique";
DROP INDEX IF EXISTS "idx_product_variant_money_amount_variant_id";
ALTER TABLE "product_variant_money_amount" ADD COLUMN IF NOT EXISTS "variant_id";
UPDATE "money_amount" SET "variant_id" = "product_variant_money_amount"."variant_id"
FROM "product_variant_money_amount"
WHERE "money_amount"."id" = "product_variant_money_amount"."money_amount_id";
DROP TABLE IF EXISTS "product_variant_money_amount";
CREATE INDEX IF NOT EXISTS idx_product_variant_money_amount_id ON money_amount (variant_id);
`
)
}
}
+1
View File
@@ -54,6 +54,7 @@ export * from "./product-type"
export * from "./product-type-tax-rate"
export * from "./product-variant"
export * from "./product-variant-inventory-item"
export * from "./product-variant-money-amount"
export * from "./publishable-api-key"
export * from "./publishable-api-key-sales-channel"
export * from "./refund"
+42 -7
View File
@@ -1,9 +1,13 @@
import {
AfterLoad,
BeforeInsert,
BeforeUpdate,
Column,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
} from "typeorm"
@@ -43,17 +47,27 @@ export class MoneyAmount extends SoftDeletableEntity {
@JoinColumn({ name: "price_list_id" })
price_list: PriceList | null
@Index('idx_money_amount_variant_id')
@Column({ nullable: true })
variant_id: string
@ManyToOne(() => ProductVariant, (variant) => variant.prices, {
@ManyToMany(() => ProductVariant, {
onDelete: "CASCADE",
})
@JoinColumn({ name: "variant_id" })
@JoinTable({
name: "product_variant_money_amount",
joinColumn: {
name: "money_amount_id",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "variant_id",
referencedColumnName: "id",
},
})
variants: ProductVariant[]
variant: ProductVariant
@Index('idx_money_amount_region_id')
variant_id: string
@Index("idx_money_amount_region_id")
@Column({ nullable: true })
region_id: string
@@ -64,6 +78,27 @@ export class MoneyAmount extends SoftDeletableEntity {
@BeforeInsert()
private beforeInsert(): undefined | void {
this.id = generateEntityId(this.id, "ma")
if (this.variant || this.variant_id) {
this.variants = [
{ id: this.variant?.id || this.variant_id },
] as ProductVariant[]
}
}
@BeforeUpdate()
private beforeUpdate(): void {
if (this.variant || this.variant_id) {
this.variants = [
{ id: this.variant?.id || this.variant_id },
] as ProductVariant[]
}
}
@AfterLoad()
private afterLoad() {
this.variant = this.variants?.[0]
this.variant_id = this.variant?.id
}
}
@@ -25,7 +25,7 @@ export class ProductCategory extends BaseEntity {
@Column()
name: string
@Column({ nullable: false, default: '' })
@Column({ nullable: false, default: "" })
description: string
@Index({ unique: true })
@@ -0,0 +1,21 @@
import { BeforeInsert, Column, Entity, Index } from "typeorm"
import { generateEntityId } from "../utils"
import { SoftDeletableEntity } from "../interfaces"
@Entity()
export class ProductVariantMoneyAmount extends SoftDeletableEntity {
@Index("idx_product_variant_money_amount_money_amount_id_unique", {
unique: true,
})
@Column()
money_amount_id: string
@Index("idx_product_variant_money_amount_variant_id")
@Column()
variant_id: string
@BeforeInsert()
private beforeInsert(): void {
this.id = generateEntityId(this.id, "pvma_")
}
}
+15 -3
View File
@@ -4,6 +4,8 @@ import {
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
OneToMany,
} from "typeorm"
@@ -28,9 +30,19 @@ export class ProductVariant extends SoftDeletableEntity {
@JoinColumn({ name: "product_id" })
product: Product
@OneToMany(() => MoneyAmount, (ma) => ma.variant, {
cascade: true,
onDelete: "CASCADE",
@ManyToMany(() => MoneyAmount, {
cascade: ["remove", "soft-remove", "recover"],
})
@JoinTable({
name: "product_variant_money_amount",
joinColumn: {
name: "variant_id",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "money_amount_id",
referencedColumnName: "id",
},
})
prices: MoneyAmount[]
+277 -34
View File
@@ -6,18 +6,24 @@ import {
ObjectLiteral,
WhereExpressionBuilder,
} from "typeorm"
import {
MoneyAmount,
ProductVariant,
ProductVariantMoneyAmount,
} from "../models"
import {
PriceListPriceCreateInput,
PriceListPriceUpdateInput,
} from "../types/price-list"
import { MoneyAmount } from "../models"
import { MedusaError } from "medusa-core-utils"
import { ProductVariantPrice } from "../types/product-variant"
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"
import { dataSource } from "../loaders/database"
import { groupBy } from "lodash"
import { isString } from "../utils"
import partition from "lodash/partition"
import { ulid } from "ulid"
type Price = Partial<
Omit<MoneyAmount, "created_at" | "updated_at" | "deleted_at">
@@ -36,15 +42,37 @@ export const MoneyAmountRepository = dataSource
.into(MoneyAmount)
.values(data)
let rawMoneyAmounts
if (!queryBuilder.connection.driver.isReturningSqlSupported("insert")) {
const rawMoneyAmounts = await queryBuilder.execute()
return rawMoneyAmounts.generatedMaps.map((d) =>
this.create(d)
) as MoneyAmount[]
rawMoneyAmounts = await queryBuilder.execute()
} else {
rawMoneyAmounts = await queryBuilder.returning("*").execute()
}
const rawMoneyAmounts = await queryBuilder.returning("*").execute()
return rawMoneyAmounts.generatedMaps.map((d) => this.create(d))
const created = rawMoneyAmounts.generatedMaps.map((d) =>
this.create(d)
) as MoneyAmount[]
const variantMoneyAmounts = this.manager.create(
ProductVariantMoneyAmount,
data
.filter(
(
d
): d is {
variant: QueryDeepPartialEntity<ProductVariant>
id: string
} => !!d.variant
)
.map((d) => ({
variant_id: d.variant.id,
money_amount_id: d.id,
}))
)
await this.manager.save(variantMoneyAmounts)
return created
},
/**
@@ -57,8 +85,15 @@ export const MoneyAmountRepository = dataSource
prices: Price[]
): Promise<MoneyAmount[]> {
const pricesNotInPricesPayload = await this.createQueryBuilder()
.where({
.leftJoinAndSelect(
"product_variant_money_amount",
"pvma",
"pvma.money_amount_id = ma.id"
)
.where("pvma.variant_id = :variant_id", {
variant_id: variantId,
})
.andWhere({
price_list_id: IsNull(),
})
.andWhere(
@@ -87,11 +122,27 @@ export const MoneyAmountRepository = dataSource
]
: variantIdOrData
const queryBuilder = this.createQueryBuilder().delete()
const maDeleteQueryBuilder = this.createQueryBuilder("ma")
const mavDeleteQueryBuilder = this.createQueryBuilder()
.delete()
.from("product_variant_money_amount")
for (const data_ of data) {
const maIdsForVariant = await this.createQueryBuilder("ma")
.leftJoin(
"product_variant_money_amount",
"pvma",
"pvma.money_amount_id = ma.id"
)
.addSelect("pvma.variant_id", "variant_id")
.addSelect("pvma.money_amount_id", "money_amount_id")
.where("pvma.variant_id = :variant_id", {
variant_id: data_.variantId,
})
.getMany()
const where = {
variant_id: data_.variantId,
id: In(maIdsForVariant.map((ma) => ma.id)),
price_list_id: IsNull(),
}
@@ -119,40 +170,72 @@ export const MoneyAmountRepository = dataSource
}
}
queryBuilder.orWhere(
maDeleteQueryBuilder.orWhere(
new Brackets((localQueryBuild) => {
localQueryBuild.where(where).andWhere(orWhere)
})
)
}
const deleteAmounts = await maDeleteQueryBuilder.getMany()
await queryBuilder.execute()
if (!deleteAmounts.length) {
return
}
await Promise.all([
this.delete(deleteAmounts.map((mav) => mav.id)),
mavDeleteQueryBuilder
.where(
deleteAmounts.map((mav) => ({
money_amount_id: mav.id,
}))
)
.execute(),
])
},
async upsertVariantCurrencyPrice(
variantId: string,
price: Price
): Promise<MoneyAmount> {
let moneyAmount = await this.findOne({
where: {
currency_code: price.currency_code,
variant_id: variantId,
region_id: IsNull(),
price_list_id: IsNull(),
},
})
let moneyAmount = await this.createQueryBuilder()
.leftJoinAndSelect(
"product_variant_money_amount",
"pvma",
"pvma.money_amount_id = ma.id"
)
.where("pvma.variant_id = :variantId", { variantId })
.andWhere("ma.currency_code = :currencyCode", {
currencyCode: price.currency_code,
})
.andWhere("ma.region_id IS NULL")
.andWhere("ma.price_list_id IS NULL")
.getOne()
const created = !moneyAmount
if (!moneyAmount) {
moneyAmount = this.create({
...price,
currency_code: price.currency_code?.toLowerCase(),
variant_id: variantId,
variant: { id: variantId },
})
} else {
moneyAmount.amount = price.amount
}
return await this.save(moneyAmount)
const createdAmount = await this.save(moneyAmount)
if (created) {
await this.createProductVariantMoneyAmounts([
{
variant_id: variantId,
money_amount_id: createdAmount.id,
},
])
}
return createdAmount
},
async addPriceListPrices(
@@ -160,11 +243,29 @@ export const MoneyAmountRepository = dataSource
prices: PriceListPriceCreateInput[],
overrideExisting = false
): Promise<MoneyAmount[]> {
const toInsert = prices.map((price) =>
this.create({
...price,
price_list_id: priceListId,
})
const [toInsert, joinTableValues] = prices.reduce(
(acc, price) => {
const [prices, joinTableValues] = acc
const id = `ma_${ulid()}`
const variant = this.create({
...price,
id,
price_list_id: priceListId,
})
const joinTableValue = this.manager.create(
ProductVariantMoneyAmount,
{
variant_id: price.variant_id!,
money_amount_id: id,
}
)
return [
[...prices, variant],
[...joinTableValues, joinTableValue],
]
},
[[], []] as [MoneyAmount[], ProductVariantMoneyAmount[]]
)
const insertResult = await this.createQueryBuilder()
@@ -172,19 +273,31 @@ export const MoneyAmountRepository = dataSource
.orIgnore(true)
.into(MoneyAmount)
.values(toInsert as QueryDeepPartialEntity<MoneyAmount>[])
.returning("*")
.execute()
if (overrideExisting) {
await this.createQueryBuilder()
const { raw } = await this.createQueryBuilder()
.delete()
.from(MoneyAmount)
.where({
price_list_id: priceListId,
id: Not(In(insertResult.identifiers.map((ma) => ma.id))),
})
.returning("id")
.execute()
await this.createQueryBuilder()
.delete()
.from("product_variant_money_amount")
.where({
money_amount_id: In(raw.map((deletedMa) => deletedMa.id)),
})
.execute()
}
await this.manager.save(joinTableValues)
return await this.manager
.createQueryBuilder(MoneyAmount, "ma")
.select()
@@ -210,7 +323,12 @@ export const MoneyAmountRepository = dataSource
): Promise<[MoneyAmount[], number]> {
const qb = this.createQueryBuilder("ma")
.leftJoinAndSelect("ma.price_list", "price_list")
.where("ma.variant_id = :variant_id", { variant_id })
.leftJoin(
"product_variant_money_amount",
"pvma",
"pvma.money_amount_id = ma.id"
)
.where("pvma.variant_id = :variant_id", { variant_id })
const getAndWhere = (subQb): WhereExpressionBuilder => {
const andWhere = subQb.where("ma.price_list_id = :price_list_id", {
@@ -256,6 +374,60 @@ export const MoneyAmountRepository = dataSource
return [result[0][variant_id], result[1]]
},
async findCurrencyMoneyAmounts(
where: { variant_id: string; currency_code: string }[]
) {
const qb = this.createQueryBuilder("ma")
.leftJoin(
"product_variant_money_amount",
"pvma",
"pvma.money_amount_id = ma.id"
)
.addSelect("pvma.variant_id", "variant_id")
where.forEach((variantCurrency, i) =>
qb.orWhere(
`(pvma.variant_id = :variant_id_${i} AND ma.currency_code = :currency_code_${i} AND ma.region_id IS NULL AND ma.price_list_id IS NULL)`,
{
[`variant_id_${i}`]: variantCurrency.variant_id,
[`currency_code_${i}`]: variantCurrency.currency_code,
}
)
)
const rawAndEntities = await qb.getRawAndEntities()
return rawAndEntities.entities.map((e, i) => {
return { ...e, variant_id: rawAndEntities.raw[i].variant_id }
})
},
async findRegionMoneyAmounts(
where: { variant_id: string; region_id: string }[]
) {
const qb = this.createQueryBuilder("ma")
.leftJoin(
"product_variant_money_amount",
"pvma",
"pvma.money_amount_id = ma.id"
)
.addSelect("pvma.variant_id", "variant_id")
where.forEach((w, i) =>
qb.orWhere(
`(pvma.variant_id = :variant_id_${i} AND ma.region_id = :region_id_${i} AND ma.price_list_id IS NULL)`,
{
[`variant_id_${i}`]: w.variant_id,
[`region_id_${i}`]: w.region_id,
}
)
)
const rawAndEntities = await qb.getRawAndEntities()
return rawAndEntities.entities.map((e, i) => {
return { ...e, variant_id: rawAndEntities.raw[i].variant_id }
})
},
async findManyForVariantsInRegion(
variant_ids: string | string[],
region_id?: string,
@@ -266,11 +438,22 @@ export const MoneyAmountRepository = dataSource
): Promise<[Record<string, MoneyAmount[]>, number]> {
variant_ids = Array.isArray(variant_ids) ? variant_ids : [variant_ids]
if (!variant_ids.length) {
return [{}, 0]
}
const date = new Date()
const qb = this.createQueryBuilder("ma")
.leftJoinAndSelect("ma.price_list", "price_list")
.where({ variant_id: In(variant_ids) })
.leftJoinAndSelect(
"product_variant_money_amount",
"pvma",
"pvma.money_amount_id = ma.id"
)
.addSelect("pvma.variant_id", "variant_id")
.andWhere("pvma.variant_id IN (:...variantIds)", {
variantIds: variant_ids,
})
.andWhere("(ma.price_list_id is null or price_list.status = 'active')")
.andWhere(
"(price_list.ends_at is null OR price_list.ends_at > :date)",
@@ -326,7 +509,16 @@ export const MoneyAmountRepository = dataSource
)
}
const [prices, count] = await qb.getManyAndCount()
const count = await qb.getCount()
const res = await qb.getRawAndEntities()
const { entities, raw } = res
const prices = entities.map((p, i) => {
p["variant_id"] = raw[i]["variant_id"]
return p
})
const groupedPrices = groupBy(prices, "variant_id")
return [groupedPrices, count]
@@ -341,11 +533,62 @@ export const MoneyAmountRepository = dataSource
(update) => update.id
)
const newPriceEntities = newPrices.map((price) =>
this.create({ ...price, price_list_id: priceListId })
const [newPriceEntities, joinTableValues] = newPrices.reduce(
(acc, price) => {
const [prices, joinTableValues] = acc
const id = `ma_${ulid()}`
const variantPrice = this.create({
...price,
id,
price_list_id: priceListId,
})
const joinTableValue = this.manager.create(
ProductVariantMoneyAmount,
{
variant_id: price.variant_id!,
money_amount_id: id,
}
)
prices.push(variantPrice)
joinTableValues.push(joinTableValue)
return [prices, joinTableValues]
},
[[], []] as [MoneyAmount[], ProductVariantMoneyAmount[]]
)
return await this.save([...existingPrices, ...newPriceEntities])
const [prices] = await Promise.all([
this.save([...existingPrices, ...newPriceEntities]),
await this.manager.save(joinTableValues),
])
return prices
},
async getPricesForVariantInRegion(
variantId: string,
regionId: string | undefined
): Promise<MoneyAmount[]> {
return this.createQueryBuilder()
.leftJoinAndSelect(
"product_variant_money_amount",
"pvma",
"pvma.money_amount_id = ma.id"
)
.where("pvma.variant_id = :variantId", { variantId })
.where("ma.region_id = :regionId", { regionId })
.getMany()
},
async createProductVariantMoneyAmounts(
toCreate: { variant_id: string; money_amount_id: string }[]
) {
return await this.createQueryBuilder()
.insert()
.into("product_variant_money_amount")
.values(toCreate)
.execute()
},
})
@@ -127,14 +127,19 @@ describe("PriceListService", () => {
updateRelatedMoneyAmountRepository.create = jest
.fn()
.mockImplementation((rawEntity) => Promise.resolve(rawEntity))
updateRelatedMoneyAmountRepository.save = jest
.fn()
.mockImplementation(() => Promise.resolve())
updateRelatedMoneyAmountRepository.updatePriceListPrices =
MoneyAmountRepository.updatePriceListPrices
const manager = {
save: (entities) => Promise.resolve(entities),
create: (entities) => Promise.resolve(entities),
}
const updateRelatedPriceListService = new PriceListService({
manager: MockManager,
manager: { ...MockManager, ...manager },
customerGroupService,
priceListRepository,
moneyAmountRepository: updateRelatedMoneyAmountRepository,
@@ -670,6 +670,16 @@ describe("ProductVariantService", () => {
insertBulk: (data) => data,
})
const productVariantRepository = MockRepository({
find: (query) => {
return Promise.resolve([
{
id: IdMap.getId("ironman"),
},
])
},
})
const oldPrices = [
{
currency_code: "dkk",
@@ -686,6 +696,20 @@ describe("ProductVariantService", () => {
moneyAmountRepository.upsertVariantCurrencyPrice = jest
.fn()
.mockImplementation(() => Promise.resolve())
moneyAmountRepository.findCurrencyMoneyAmounts = jest
.fn()
.mockImplementation(() =>
Promise.resolve([
{
id: IdMap.getId("dkk"),
variant_id: IdMap.getId("ironman"),
currency_code: "dkk",
},
])
)
moneyAmountRepository.findRegionMoneyAmounts = jest
.fn()
.mockImplementation(() => Promise.resolve([]))
const priceSelectionStrategy = {
withTransaction: function () {
@@ -723,6 +747,7 @@ describe("ProductVariantService", () => {
regionService,
priceSelectionStrategy,
moneyAmountRepository,
productVariantRepository,
})
beforeEach(async () => {
@@ -730,7 +755,7 @@ describe("ProductVariantService", () => {
})
it("successfully removes obsolete prices and create new prices", async () => {
await productVariantService.updateVariantPrices("ironman", [
await productVariantService.updateVariantPrices(IdMap.getId("ironman"), [
{
currency_code: "usd",
amount: 4000,
@@ -744,7 +769,7 @@ describe("ProductVariantService", () => {
expect(moneyAmountRepository.insertBulk).toHaveBeenCalledTimes(1)
expect(moneyAmountRepository.insertBulk).toHaveBeenCalledWith([
{
variant_id: "ironman",
variant: { id: IdMap.getId("ironman") },
currency_code: "usd",
amount: 4000,
},
@@ -761,7 +786,7 @@ describe("ProductVariantService", () => {
expect(moneyAmountRepository.create).toHaveBeenCalledTimes(1)
expect(moneyAmountRepository.create).toHaveBeenCalledWith({
variant_id: IdMap.getId("ironman"),
variant: { id: IdMap.getId("ironman") },
region_id: IdMap.getId("cali"),
currency_code: "usd",
amount: 100,
+40 -21
View File
@@ -1,27 +1,28 @@
import { FlagRouter } from "@medusajs/utils"
import { isDefined, MedusaError } from "medusa-core-utils"
import { DeepPartial, EntityManager } from "typeorm"
import { CustomerGroupService } from "."
import { TransactionBaseService } from "../interfaces"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
import { CustomerGroup, PriceList, Product, ProductVariant } from "../models"
import { MoneyAmountRepository } from "../repositories/money-amount"
import { PriceListRepository } from "../repositories/price-list"
import { ProductVariantRepository } from "../repositories/product-variant"
import { FindConfig, Selector } from "../types/common"
import {
CreatePriceListInput,
FilterablePriceListProps,
PriceListPriceCreateInput,
PriceListPriceUpdateInput,
UpdatePriceListInput,
CreatePriceListInput,
FilterablePriceListProps,
PriceListPriceCreateInput,
PriceListPriceUpdateInput,
UpdatePriceListInput,
} from "../types/price-list"
import { CustomerGroup, PriceList, Product, ProductVariant } from "../models"
import { DeepPartial, EntityManager } from "typeorm"
import { FindConfig, Selector } from "../types/common"
import { MedusaError, isDefined } from "medusa-core-utils"
import { CustomerGroupService } from "."
import { FilterableProductProps } from "../types/product"
import { FilterableProductVariantProps } from "../types/product-variant"
import { buildQuery } from "../utils"
import { FlagRouter } from "@medusajs/utils"
import { MoneyAmountRepository } from "../repositories/money-amount"
import { PriceListRepository } from "../repositories/price-list"
import ProductService from "./product"
import { ProductVariantRepository } from "../repositories/product-variant"
import ProductVariantService from "./product-variant"
import RegionService from "./region"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
import { TransactionBaseService } from "../interfaces"
import { buildQuery } from "../utils"
type PriceListConstructorProps = {
manager: EntityManager
@@ -366,10 +367,12 @@ class PriceListService extends TransactionBaseService {
requiresPriceList
)
return productVariantRepo.create({
const variant = productVariantRepo.create({
...v,
prices,
})
variant.prices = prices
return variant
})
)
}
@@ -497,13 +500,29 @@ class PriceListService extends TransactionBaseService {
const regionServiceTx = this.regionService_.withTransaction(
this.activeManager_
)
const regions = await regionServiceTx.list(
{
id: [
...new Set(
prices
.map((p) => p.region_id)
.filter((p: string | undefined): p is string => !!p)
),
],
},
{}
)
const regionsMap = new Map(regions.map((r) => [r.id, r]))
for (const price of prices) {
const p = { ...price }
if (p.region_id) {
const region = await regionServiceTx.retrieve(p.region_id)
const region = regionsMap.get(p.region_id)
p.currency_code = region.currency_code
p.currency_code = region!.currency_code
}
prices_.push(p)
+8 -8
View File
@@ -4,18 +4,18 @@ import { EntityManager } from "typeorm"
import { ProductVariantService, RegionService, TaxProviderService } from "."
import { TransactionBaseService } from "../interfaces"
import {
IPriceSelectionStrategy,
PriceSelectionContext,
IPriceSelectionStrategy,
PriceSelectionContext,
} from "../interfaces/price-selection-strategy"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
import { Product, ProductVariant, Region, ShippingOption } from "../models"
import {
PricedProduct,
PricedShippingOption,
PricedVariant,
PricingContext,
ProductVariantPricing,
TaxedPricing,
PricedProduct,
PricedShippingOption,
PricedVariant,
PricingContext,
ProductVariantPricing,
TaxedPricing,
} from "../types/pricing"
import { TaxServiceRate } from "../types/tax-service"
import { calculatePriceTaxAmount } from "../utils"
+60 -53
View File
@@ -1,4 +1,3 @@
import { isDefined, MedusaError } from "medusa-core-utils"
import {
Brackets,
EntityManager,
@@ -8,25 +7,8 @@ import {
FindOptionsWhere,
ILike,
In,
IsNull,
SelectQueryBuilder,
} from "typeorm"
import {
IPriceSelectionStrategy,
PriceSelectionContext,
TransactionBaseService,
} from "../interfaces"
import {
MoneyAmount,
Product,
ProductOptionValue,
ProductVariant,
} from "../models"
import {
FindWithRelationsOptions,
ProductVariantRepository,
} from "../repositories/product-variant"
import { FindConfig, WithRequiredProperty } from "../types/common"
import {
CreateProductVariantInput,
FilterableProductVariantProps,
@@ -38,6 +20,23 @@ import {
UpdateVariantPricesData,
UpdateVariantRegionPriceData,
} from "../types/product-variant"
import { FindConfig, WithRequiredProperty } from "../types/common"
import {
FindWithRelationsOptions,
ProductVariantRepository,
} from "../repositories/product-variant"
import {
IPriceSelectionStrategy,
PriceSelectionContext,
TransactionBaseService,
} from "../interfaces"
import { MedusaError, isDefined } from "medusa-core-utils"
import {
MoneyAmount,
Product,
ProductOptionValue,
ProductVariant,
} from "../models"
import {
buildQuery,
hasChanges,
@@ -46,12 +45,12 @@ import {
setMetadata,
} from "../utils"
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"
import { CartRepository } from "../repositories/cart"
import { MoneyAmountRepository } from "../repositories/money-amount"
import { ProductRepository } from "../repositories/product"
import { ProductOptionValueRepository } from "../repositories/product-option-value"
import EventBusService from "./event-bus"
import { MoneyAmountRepository } from "../repositories/money-amount"
import { ProductOptionValueRepository } from "../repositories/product-option-value"
import { ProductRepository } from "../repositories/product"
import { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity"
import RegionService from "./region"
import { buildRelations } from "@medusajs/utils"
@@ -398,6 +397,7 @@ class ProductVariantService extends TransactionBaseService {
{ id },
{ id, ...toUpdate }
)
result = variantRepo.create({
...variant,
...rawResult.generatedMaps[0],
@@ -544,16 +544,16 @@ class ProductVariantService extends TransactionBaseService {
const moneyAmountRepo = manager.withRepository(
this.moneyAmountRepository_
)
const productVariantRepo = this.activeManager_.withRepository(
this.productVariantRepository_
)
const where = data.map((data_) => ({
variant_id: data_.variantId,
region_id: data_.price.region_id,
price_list_id: IsNull(),
}))
const moneyAmounts = await moneyAmountRepo.find({
where,
})
const moneyAmounts = await moneyAmountRepo.findRegionMoneyAmounts(where)
const moneyAmountsMapToVariantId = new Map()
moneyAmounts.map((d) => {
@@ -582,12 +582,11 @@ class ProductVariantService extends TransactionBaseService {
})
}
} else {
dataToCreate.push(
moneyAmountRepo.create({
...price,
variant_id: variantId,
}) as QueryDeepPartialEntity<MoneyAmount>
)
const ma = moneyAmountRepo.create({
...price,
}) as QueryDeepPartialEntity<MoneyAmount>
ma.variant = { id: variantId }
dataToCreate.push(ma)
}
})
@@ -626,17 +625,16 @@ class ProductVariantService extends TransactionBaseService {
const moneyAmountRepo = manager.withRepository(
this.moneyAmountRepository_
)
const productVariantRepo = this.activeManager_.withRepository(
this.productVariantRepository_
)
const where = data.map((data_) => ({
variant_id: data_.variantId,
currency_code: data_.price.currency_code,
region_id: IsNull(),
price_list_id: IsNull(),
}))
const moneyAmounts = await moneyAmountRepo.find({
where,
})
const moneyAmounts = await moneyAmountRepo.findCurrencyMoneyAmounts(where)
const moneyAmountsMapToVariantId = new Map()
moneyAmounts.map((d) => {
@@ -665,13 +663,12 @@ class ProductVariantService extends TransactionBaseService {
})
}
} else {
dataToCreate.push(
moneyAmountRepo.create({
...price,
variant_id: variantId,
currency_code: price.currency_code.toLowerCase(),
}) as QueryDeepPartialEntity<MoneyAmount>
)
const ma = moneyAmountRepo.create({
...price,
currency_code: price.currency_code.toLowerCase(),
}) as QueryDeepPartialEntity<MoneyAmount>
ma.variant = { id: variantId }
dataToCreate.push(ma)
}
})
@@ -746,24 +743,34 @@ class ProductVariantService extends TransactionBaseService {
this.moneyAmountRepository_
)
let moneyAmount = await moneyAmountRepo.findOne({
where: {
variant_id: variantId,
region_id: price.region_id,
price_list_id: IsNull(),
},
})
let [moneyAmount] = await moneyAmountRepo.getPricesForVariantInRegion(
variantId,
price.region_id
)
const created = !moneyAmount
if (!moneyAmount) {
moneyAmount = moneyAmountRepo.create({
...price,
variant_id: variantId,
variant: { id: variantId },
})
} else {
moneyAmount.amount = price.amount
}
return await moneyAmountRepo.save(moneyAmount)
const createdAmount = await moneyAmountRepo.save(moneyAmount)
if (created) {
await moneyAmountRepo.createProductVariantMoneyAmounts([
{
variant_id: variantId,
money_amount_id: createdAmount.id,
},
])
}
return createdAmount
})
}
@@ -1,15 +1,10 @@
import { EntityManager } from "typeorm"
import { computerizeAmount, MedusaError } from "medusa-core-utils"
import { AbstractBatchJobStrategy, IFileService } from "../../../interfaces"
import CsvParser from "../../../services/csv-parser"
import {
BatchJobService,
PriceListService,
ProductVariantService,
RegionService,
} from "../../../services"
import { CreateBatchJobInput } from "../../../types/batch-job"
import {
InjectedProps,
OperationType,
@@ -21,9 +16,14 @@ import {
TBuiltPriceListImportLine,
TParsedPriceListImportRowData,
} from "./types"
import { MedusaError, computerizeAmount } from "medusa-core-utils"
import { BatchJob } from "../../../models"
import { TParsedProductImportRowData } from "../product/types"
import { CreateBatchJobInput } from "../../../types/batch-job"
import CsvParser from "../../../services/csv-parser"
import { EntityManager } from "typeorm"
import { PriceListPriceCreateInput } from "../../../types/price-list"
import { TParsedProductImportRowData } from "../product/types"
/*
* Default strategy class used for a batch import of products/variants.
@@ -309,12 +309,14 @@ class PriceListImportStrategy extends AbstractBatchJobStrategy {
try {
await txPriceListService.addPrices(
priceListId,
(op.prices as PriceListPriceCreateInput[]).map((p) => {
return {
...p,
variant_id: op.variant_id,
(op.prices as PriceListPriceCreateInput[]).map(
(p: PriceListPriceCreateInput) => {
return {
...p,
variant_id: op.variant_id as string,
}
}
})
)
)
} catch (e) {
PriceListImportStrategy.throwDescriptiveError(op, e.message)
@@ -1,13 +1,13 @@
import {
AbstractPriceSelectionStrategy,
PriceSelectionContext,
PriceSelectionResult,
PriceType,
AbstractPriceSelectionStrategy,
PriceSelectionContext,
PriceSelectionResult,
PriceType,
} from "../interfaces"
import { ICacheService } from "@medusajs/types"
import { FlagRouter } from "@medusajs/utils"
import { isDefined } from "medusa-core-utils"
import { MedusaError, isDefined } from "medusa-core-utils"
import { EntityManager } from "typeorm"
import TaxInclusivePricingFeatureFlag from "../loaders/feature-flags/tax-inclusive-pricing"
import { MoneyAmountRepository } from "../repositories/money-amount"
+1
View File
@@ -158,6 +158,7 @@ export type PriceListPriceUpdateInput = {
export type PriceListPriceCreateInput = {
region_id?: string
currency_code?: string
variant_id: string
amount: number
min_quantity?: number
max_quantity?: number
@@ -0,0 +1,36 @@
export const defaultPriceListData = [
{
name: 'pl-1',
description: 'pl-1',
prices: [ {
id: "money-amount-USD",
currency_code: "USD",
amount: 500,
min_quantity: 1,
max_quantity: 10,
price_list_id: 'pl-1'
},
{
id: "money-amount-EUR",
currency_code: "EUR",
amount: 400,
min_quantity: 1,
max_quantity: 5,
price_list_id: 'pl-1'
}]
},
{
id: 'pl-2',
name: 'pl-2',
description: 'pl-2',
prices: [{
id: "money-amount-CAD",
currency_code: "CAD",
amount: 600,
min_quantity: 1,
max_quantity: 8,
price_list_id: 'pl-2'
}]
}
]
@@ -0,0 +1,25 @@
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { MoneyAmount, PriceList } from "@models"
import { defaultPriceListData } from "./data"
export async function createPriceLists(
manager: SqlEntityManager,
priceListsData: any[] = defaultPriceListData,
): Promise<MoneyAmount[]> {
const priceLists: PriceList[] = []
const moneyAmounts: MoneyAmount[] = []
for (let priceListdata of priceListsData) {
const { prices, ...rest } = priceListdata
const priceList = manager.create(MoneyAmount, rest)
priceLists.push(priceList)
const createdPrices = manager.create(MoneyAmount, prices)
moneyAmounts.push(createdPrices)
}
await manager.persistAndFlush(priceLists)
await manager.persistAndFlush(moneyAmounts)
return priceLists
}
@@ -0,0 +1,335 @@
import { SqlEntityManager } from "@mikro-orm/postgresql"
import { Currency, MoneyAmount } from "@models"
import { MoneyAmountRepository } from "@repositories"
import { MoneyAmountService } from "@services"
import { createCurrencies } from "../../../__fixtures__/currency"
import { createMoneyAmounts } from "../../../__fixtures__/money-amount"
import { MikroOrmWrapper } from "../../../utils"
jest.setTimeout(30000)
describe("MoneyAmount Service", () => {
let service: MoneyAmountService
let testManager: SqlEntityManager
let repositoryManager: SqlEntityManager
let data!: MoneyAmount[]
let currencyData!: Currency[]
beforeEach(async () => {
await MikroOrmWrapper.setupDatabase()
repositoryManager = await MikroOrmWrapper.forkManager()
const moneyAmountRepository = new MoneyAmountRepository({
manager: repositoryManager,
})
service = new MoneyAmountService({
moneyAmountRepository,
})
testManager = await MikroOrmWrapper.forkManager()
currencyData = await createCurrencies(testManager)
data = await createMoneyAmounts(testManager)
})
afterEach(async () => {
await MikroOrmWrapper.clearDatabase()
})
describe("list", () => {
it("list moneyAmounts", async () => {
const moneyAmountsResult = await service.list()
expect(moneyAmountsResult).toEqual([
expect.objectContaining({
id: "money-amount-USD",
amount: "500",
}),
expect.objectContaining({
id: "money-amount-EUR",
amount: "400",
}),
expect.objectContaining({
id: "money-amount-CAD",
amount: "600",
}),
])
})
it("list moneyAmounts by id", async () => {
const moneyAmountsResult = await service.list({
id: ["money-amount-USD"],
})
expect(moneyAmountsResult).toEqual([
expect.objectContaining({
id: "money-amount-USD",
}),
])
})
it("list moneyAmounts with relations and selects", async () => {
const moneyAmountsResult = await service.list(
{
id: ["money-amount-USD"],
},
{
select: ["id", "min_quantity", "currency.code"],
relations: ["currency"],
}
)
const serialized = JSON.parse(JSON.stringify(moneyAmountsResult))
expect(serialized).toEqual([
{
id: "money-amount-USD",
min_quantity: "1",
currency_code: "USD",
currency: {
code: "USD",
},
},
])
})
})
describe("listAndCount", () => {
it("should return moneyAmounts and count", async () => {
const [moneyAmountsResult, count] = await service.listAndCount()
expect(count).toEqual(3)
expect(moneyAmountsResult).toEqual([
expect.objectContaining({
id: "money-amount-USD",
}),
expect.objectContaining({
id: "money-amount-EUR",
}),
expect.objectContaining({
id: "money-amount-CAD",
}),
])
})
it("should return moneyAmounts and count when filtered", async () => {
const [moneyAmountsResult, count] = await service.listAndCount({
id: ["money-amount-USD"],
})
expect(count).toEqual(1)
expect(moneyAmountsResult).toEqual([
expect.objectContaining({
id: "money-amount-USD",
}),
])
})
it("list moneyAmounts with relations and selects", async () => {
const [moneyAmountsResult, count] = await service.listAndCount(
{
id: ["money-amount-USD"],
},
{
select: ["id", "min_quantity", "currency.code"],
relations: ["currency"],
}
)
const serialized = JSON.parse(JSON.stringify(moneyAmountsResult))
expect(count).toEqual(1)
expect(serialized).toEqual([
{
id: "money-amount-USD",
min_quantity: "1",
currency_code: "USD",
currency: {
code: "USD",
},
},
])
})
it("should return moneyAmounts and count when using skip and take", async () => {
const [moneyAmountsResult, count] = await service.listAndCount(
{},
{ skip: 1, take: 1 }
)
expect(count).toEqual(3)
expect(moneyAmountsResult).toEqual([
expect.objectContaining({
id: "money-amount-EUR",
}),
])
})
it("should return requested fields", async () => {
const [moneyAmountsResult, count] = await service.listAndCount(
{},
{
take: 1,
select: ["id"],
}
)
const serialized = JSON.parse(JSON.stringify(moneyAmountsResult))
expect(count).toEqual(3)
expect(serialized).toEqual([
{
id: "money-amount-USD",
},
])
})
})
describe("retrieve", () => {
const id = "money-amount-USD"
const amount = "500"
it("should return moneyAmount for the given id", async () => {
const moneyAmount = await service.retrieve(id)
expect(moneyAmount).toEqual(
expect.objectContaining({
id,
})
)
})
it("should throw an error when moneyAmount with id does not exist", async () => {
let error
try {
await service.retrieve("does-not-exist")
} catch (e) {
error = e
}
expect(error.message).toEqual(
"MoneyAmount with id: does-not-exist was not found"
)
})
it("should throw an error when a id is not provided", async () => {
let error
try {
await service.retrieve(undefined as unknown as string)
} catch (e) {
error = e
}
expect(error.message).toEqual('"moneyAmountId" must be defined')
})
it("should return moneyAmount based on config select param", async () => {
const moneyAmount = await service.retrieve(id, {
select: ["id", "amount"],
})
const serialized = JSON.parse(JSON.stringify(moneyAmount))
expect(serialized).toEqual({
id,
amount,
})
})
})
describe("delete", () => {
const id = "money-amount-USD"
it("should delete the moneyAmounts given an id successfully", async () => {
await service.delete([id])
const moneyAmounts = await service.list({
id: [id],
})
expect(moneyAmounts).toHaveLength(0)
})
})
describe("update", () => {
const id = "money-amount-USD"
it("should update the amount of the moneyAmount successfully", async () => {
await service.update([
{
id,
amount: 700,
},
])
const moneyAmount = await service.retrieve(id)
expect(moneyAmount.amount).toEqual("700")
})
it("should update the currency of the moneyAmount successfully", async () => {
await service.update([
{
id,
currency_code: "EUR",
},
])
const moneyAmount = await service.retrieve(id)
expect(moneyAmount.currency_code).toEqual("EUR")
expect(moneyAmount.currency?.code).toEqual("EUR")
})
it("should throw an error when a id does not exist", async () => {
let error
try {
await service.update([
{
id: "does-not-exist",
amount: 666,
},
])
} catch (e) {
error = e
}
expect(error.message).toEqual(
'MoneyAmount with id "does-not-exist" not found'
)
})
})
describe("create", () => {
it("should create a moneyAmount successfully", async () => {
await service.create([
{
id: "money-amount-TESM",
currency_code: "USD",
amount: 333,
min_quantity: 1,
max_quantity: 4,
},
])
const [moneyAmount] = await service.list({
id: ["money-amount-TESM"],
})
expect(moneyAmount).toEqual(
expect.objectContaining({
id: "money-amount-TESM",
currency_code: "USD",
amount: "333",
min_quantity: "1",
max_quantity: "4",
})
)
})
})
})
@@ -17,7 +17,7 @@ export async function createProductCategories(
parentCategory = await manager.findOne(ProductCategory, parentCategoryId)
}
const category = await manager.create(ProductCategory, {
const category = manager.create(ProductCategory, {
...categoryDataClone,
parent_category: parentCategory,
})
+1 -1
View File
@@ -72,7 +72,7 @@ async function createProductCategories(
parentCategory = await manager.findOne(ProductCategory, parentCategoryId)
}
const category = await manager.create(ProductCategory, {
const category = manager.create(ProductCategory, {
...categoryDataClone,
parent_category: parentCategory,
})