feat(medusa): Add purchasable prop on variants when setting availability (#3811)

* write integration tests

* update variant inventory decorator

* update types

* add changeset

* feedback comments

* add yaml schemas

* different oas approach

* pr feedback

* update oas

---------

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Philip Korsholm
2023-04-20 08:54:26 +02:00
committed by GitHub
parent 7d1a78a84f
commit 2be144ff05
27 changed files with 395 additions and 70 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
fix(medusa): add purchasable property

View File

@@ -0,0 +1,10 @@
title: Product with decorated variants
type: object
allOf:
- $ref: ./Product.yaml
- type: object
properties:
variants:
type: array
items:
$ref: ./DecoratedVariant.yaml

View File

@@ -0,0 +1,9 @@
title: Decorated Product Variant
type: object
allOf:
- $ref: ./PricedVariant.yaml
- type: object
properties:
purchasable:
type: boolean
description: Boolean indicating if variant is purchasable.

View File

@@ -0,0 +1,10 @@
title: Product with decorated variants
type: object
allOf:
- $ref: ./Product.yaml
- type: object
properties:
variants:
type: array
items:
$ref: ./DecoratedVariant.yaml

View File

@@ -0,0 +1,9 @@
title: Decorated Product Variant
type: object
allOf:
- $ref: ./PricedVariant.yaml
- type: object
properties:
purchasable:
type: boolean
description: Boolean indicating if variant is purchasable.

View File

@@ -50,6 +50,7 @@ describe("Create Variant", () => {
describe("list-products", () => {
const productId = "test-product"
const variantId = "test-variant"
let sc2
beforeEach(async () => {
await adminSeeder(dbConnection)
@@ -66,13 +67,14 @@ describe("Create Variant", () => {
dbConnection,
{
id: productId,
status: "published",
variants: [{ id: variantId }],
},
100
)
const sc1 = await simpleSalesChannelFactory(dbConnection, {})
const sc2 = await simpleSalesChannelFactory(dbConnection, {})
sc2 = await simpleSalesChannelFactory(dbConnection, {})
const sc3 = await simpleSalesChannelFactory(dbConnection, {})
const sl1 = await stockLocationService.create({ name: "sl1" })
@@ -120,5 +122,203 @@ describe("Create Variant", () => {
}),
])
})
describe("/store/products", () => {
beforeEach(async () => {
const inventoryService = appContainer.resolve("inventoryService")
const productVariantInventoryService = appContainer.resolve(
"productVariantInventoryService"
)
await simpleProductFactory(
dbConnection,
{
id: `${productId}-1`,
status: "published",
variants: [
{
id: `${variantId}-1`,
manage_inventory: false,
},
],
},
101
)
await simpleProductFactory(
dbConnection,
{
id: `${productId}-2`,
status: "published",
variants: [{ id: `${variantId}-2`, manage_inventory: true }],
},
102
)
await simpleProductFactory(
dbConnection,
{
id: `${productId}-3`,
status: "published",
variants: [
{
id: `${variantId}-3`,
manage_inventory: true,
allow_backorder: true,
},
],
},
103
)
const invItem = await inventoryService.createInventoryItem({
sku: "test-sku-1",
})
await productVariantInventoryService.attachInventoryItem(
`${variantId}-3`,
invItem.id
)
await simpleProductFactory(
dbConnection,
{
id: `${productId}-4`,
status: "published",
variants: [
{
id: `${variantId}-4`,
manage_inventory: true,
allow_backorder: false,
},
],
},
104
)
const invItem1 = await inventoryService.createInventoryItem({
sku: "test-sku-2",
})
await productVariantInventoryService.attachInventoryItem(
`${variantId}-4`,
invItem1.id
)
})
it("lists location availability correctly for store", async () => {
const api = useApi()
const res = await api.get(`/store/products`)
expect(res.status).toEqual(200)
expect(res.data.products).toEqual([
expect.objectContaining({
id: `${productId}-4`,
variants: [
expect.objectContaining({
purchasable: false,
}),
],
}),
expect.objectContaining({
id: `${productId}-3`,
variants: [
expect.objectContaining({
purchasable: false,
}),
],
}),
expect.objectContaining({
id: `${productId}-2`,
variants: [
expect.objectContaining({
purchasable: true,
}),
],
}),
expect.objectContaining({
id: `${productId}-1`,
variants: [
expect.objectContaining({
inventory_quantity: 10,
purchasable: true,
}),
],
}),
expect.objectContaining({
id: productId,
variants: [
expect.objectContaining({
purchasable: false,
}),
],
}),
])
})
it("lists location availability correctly for store with sales channel id", async () => {
const api = useApi()
const productService = appContainer.resolve("productService")
const ids = [
`${productId}`,
`${productId}-1`,
`${productId}-2`,
`${productId}-3`,
`${productId}-4`,
]
for (const id of ids) {
await productService.update(id, {
sales_channels: [{ id: sc2.id }],
})
}
const res = await api.get(
`/store/products?sales_channel_id[]=${sc2.id}`
)
expect(res.status).toEqual(200)
expect(res.data.products).toEqual([
expect.objectContaining({
id: `${productId}-4`,
variants: [
expect.objectContaining({
purchasable: false,
}),
],
}),
expect.objectContaining({
id: `${productId}-3`,
variants: [
expect.objectContaining({
purchasable: true,
}),
],
}),
expect.objectContaining({
id: `${productId}-2`,
variants: [
expect.objectContaining({
purchasable: true,
}),
],
}),
expect.objectContaining({
id: `${productId}-1`,
variants: [
expect.objectContaining({
inventory_quantity: 10,
purchasable: true,
}),
],
}),
expect.objectContaining({
id: productId,
variants: [
expect.objectContaining({
purchasable: true,
inventory_quantity: 4,
}),
],
}),
])
})
})
})
})

View File

@@ -1,14 +1,15 @@
import { Product, ProductOption, ProductStatus, ProductType, ShippingProfile, ShippingProfileType, } from "@medusajs/medusa"
import { ProductVariantFactoryData, simpleProductVariantFactory, } from "./simple-product-variant-factory"
import { Connection } from "typeorm"
import faker from "faker"
import { Product, ProductOption, ProductType, ShippingProfile, ShippingProfileType, } from "@medusajs/medusa"
import { ProductVariantFactoryData, simpleProductVariantFactory, } from "./simple-product-variant-factory"
export type ProductFactoryData = {
id?: string
is_giftcard?: boolean
title?: string
type?: string
status?: ProductStatus
options?: { id: string; title: string }[]
variants?: ProductVariantFactoryData[]
}
@@ -45,6 +46,7 @@ export const simpleProductFactory = async (
const toSave = manager.create(Product, {
id: prodId,
type_id: typeId,
status: data.status || ProductStatus.DRAFT,
title: data.title || faker.commerce.productName(),
is_giftcard: data.is_giftcard || false,
discountable: !data.is_giftcard,

View File

@@ -1,17 +1,20 @@
import { Connection } from "typeorm"
import faker from "faker"
import {
MoneyAmount,
ProductOptionValue,
ProductVariant,
MoneyAmount,
} from "@medusajs/medusa"
import { Connection } from "typeorm"
import faker from "faker"
export type ProductVariantFactoryData = {
product_id: string
id?: string
is_giftcard?: boolean
inventory_quantity?: number
title?: string
allow_backorder?: boolean
manage_inventory?: boolean
options?: { option_id: string; value: string }[]
prices?: { currency: string; amount: number }[]
}
@@ -31,6 +34,8 @@ export const simpleProductVariantFactory = async (
const toSave = manager.create(ProductVariant, {
id,
product_id: data.product_id,
allow_backorder: data.allow_backorder || false,
manage_inventory: typeof data.manage_inventory !== 'undefined' ? data.manage_inventory : true,
inventory_quantity:
typeof data.inventory_quantity !== "undefined"
? data.inventory_quantity

View File

@@ -14,7 +14,9 @@ export interface AdminProductsListRes {
"collection" | "images" | "options" | "tags" | "type" | "variants"
>,
{
variants: Array<SetRelation<ProductVariant, "options" | "prices">>
variants: Array<
SetRelation<ProductVariant, "options" | "prices" | "purchasable">
>
}
>
>

View File

@@ -6,7 +6,9 @@ import { SetRelation, Merge } from "../core/ModelUtils"
import type { PricedVariant } from "./PricedVariant"
export interface AdminVariantsListRes {
variants: Array<SetRelation<PricedVariant, "options" | "prices" | "product">>
variants: Array<
SetRelation<PricedVariant, "options" | "prices" | "product" | "purchasable">
>
/**
* The total number of items available
*/

View File

@@ -120,4 +120,15 @@ export interface ProductVariant {
* An optional key-value map with additional details
*/
metadata: Record<string, any> | null
/**
* Only used with the inventory modules.
* A boolean value indicating whether the Product Variant is purchasable.
* A variant is purchasable if:
* - inventory is not managed
* - it has no inventory items
* - it is in stock
* - it is backorderable.
*
*/
purchasable?: boolean
}

View File

@@ -16,7 +16,9 @@ export interface StoreProductsListRes {
>,
{
options: Array<SetRelation<ProductOption, "values">>
variants: Array<SetRelation<ProductVariant, "options" | "prices">>
variants: Array<
SetRelation<ProductVariant, "options" | "prices" | "purchasable">
>
}
>
>

View File

@@ -15,7 +15,9 @@ export interface StoreProductsRes {
>,
{
options: Array<SetRelation<ProductOption, "values">>
variants: Array<SetRelation<ProductVariant, "options" | "prices">>
variants: Array<
SetRelation<ProductVariant, "options" | "prices" | "purchasable">
>
}
>
}

View File

@@ -6,5 +6,7 @@ import { SetRelation, Merge } from "../core/ModelUtils"
import type { PricedVariant } from "./PricedVariant"
export interface StoreVariantsListRes {
variants: Array<SetRelation<PricedVariant, "prices" | "options" | "product">>
variants: Array<
SetRelation<PricedVariant, "prices" | "options" | "product" | "purchasable">
>
}

View File

@@ -6,5 +6,8 @@ import { SetRelation, Merge } from "../core/ModelUtils"
import type { PricedVariant } from "./PricedVariant"
export interface StoreVariantsRes {
variant: SetRelation<PricedVariant, "prices" | "options" | "product">
variant: SetRelation<
PricedVariant,
"prices" | "options" | "product" | "purchasable"
>
}

View File

@@ -114,13 +114,6 @@ export default async (req, res) => {
select: defaultOrderFields,
})
// TODO: Re-enable when we have a way to handle inventory for draft orders on creation
if (!inventoryService) {
await reserveQuantityForDraftOrder(order, {
productVariantInventoryService,
})
}
return order
})

View File

@@ -1,12 +1,14 @@
import { Router } from "express"
import "reflect-metadata"
import { Product, ProductTag, ProductType, ProductVariant } from "../../../.."
import { FindParams, PaginatedResponse } from "../../../../types/common"
import { PricedProduct } from "../../../../types/pricing"
import { FlagRouter } from "../../../../utils/flag-router"
import { Product, ProductTag, ProductType, ProductVariant } from "../../../.."
import middlewares, { transformQuery } from "../../../middlewares"
import { validateSalesChannelsExist } from "../../../middlewares/validators/sales-channel-existence"
import { AdminGetProductsParams } from "./list-products"
import { FlagRouter } from "../../../../utils/flag-router"
import { Router } from "express"
import { validateSalesChannelsExist } from "../../../middlewares/validators/sales-channel-existence"
import { PricedProduct } from "../../../../types/pricing"
const route = Router()
@@ -258,6 +260,8 @@ export type AdminProductsDeleteRes = {
* - variants
* - variants.options
* - variants.prices
* totals:
* - variants.purchasable
* required:
* - products
* - count

View File

@@ -7,10 +7,10 @@ import {
} from "../../../../services"
import { FilterableProductProps } from "../../../../types/product"
import { IInventoryService } from "@medusajs/types"
import { PricedProduct } from "../../../../types/pricing"
import { Product } from "../../../../models"
import { Type } from "class-transformer"
import { IInventoryService } from "@medusajs/types"
/**
* @oas [get] /admin/products

View File

@@ -1,4 +1,5 @@
import { PricingService, ProductVariantService } from "../../../../services"
import { FindParams } from "../../../../types/common"
/**

View File

@@ -1,12 +1,12 @@
import { Router } from "express"
import { ProductVariant } from "../../../../models/product-variant"
import { PaginatedResponse } from "../../../../types/common"
import { PricedVariant } from "../../../../types/pricing"
import middlewares, { transformQuery } from "../../../middlewares"
import { checkRegisteredModules } from "../../../middlewares/check-registered-modules"
import { AdminGetVariantParams } from "./get-variant"
import { AdminGetVariantsParams } from "./list-variants"
import { PaginatedResponse } from "../../../../types/common"
import { PricedVariant } from "../../../../types/pricing"
import { ProductVariant } from "../../../../models/product-variant"
import { Router } from "express"
import { checkRegisteredModules } from "../../../middlewares/check-registered-modules"
const route = Router()
@@ -81,6 +81,8 @@ export const defaultAdminVariantFields: (keyof ProductVariant)[] = [
* - options
* - prices
* - product
* totals:
* - purchasable
* required:
* - variants
* - count

View File

@@ -1,15 +1,17 @@
import { Router } from "express"
import "reflect-metadata"
import { Product } from "../../../.."
import { PaginatedResponse } from "../../../../types/common"
import { FlagRouter } from "../../../../utils/flag-router"
import middlewares, { transformStoreQuery } from "../../../middlewares"
import { FlagRouter } from "../../../../utils/flag-router"
import { PaginatedResponse } from "../../../../types/common"
import { PricedProduct } from "../../../../types/pricing"
import { Product } from "../../../.."
import { Router } from "express"
import { StoreGetProductsParams } from "./list-products"
import { StoreGetProductsProductParams } from "./get-product"
import { extendRequestParams } from "../../../middlewares/publishable-api-key/extend-request-params"
import { validateProductSalesChannelAssociation } from "../../../middlewares/publishable-api-key/validate-product-sales-channel-association"
import { validateSalesChannelParam } from "../../../middlewares/publishable-api-key/validate-sales-channel-param"
import { StoreGetProductsProductParams } from "./get-product"
import { StoreGetProductsParams } from "./list-products"
const route = Router()
@@ -122,6 +124,8 @@ export * from "./search"
* - variants
* - variants.options
* - variants.prices
* totals:
* - variants.purchasable
* required:
* - product
* properties:
@@ -129,7 +133,7 @@ export * from "./search"
* $ref: "#/components/schemas/PricedProduct"
*/
export type StoreProductsRes = {
product: Product
product: PricedProduct
}
/**
@@ -163,6 +167,8 @@ export type StorePostSearchRes = {
* - variants
* - variants.options
* - variants.prices
* totals:
* - variants.purchasable
* required:
* - products
* - count
@@ -184,5 +190,5 @@ export type StorePostSearchRes = {
* description: The number of items per page
*/
export type StoreProductsListRes = PaginatedResponse & {
products: Product[]
products: PricedProduct[]
}

View File

@@ -1,9 +1,9 @@
import { Router } from "express"
import { PricedVariant } from "../../../../types/pricing"
import middlewares from "../../../middlewares"
import { extendRequestParams } from "../../../middlewares/publishable-api-key/extend-request-params"
import { validateSalesChannelParam } from "../../../middlewares/publishable-api-key/validate-sales-channel-param"
import middlewares from "../../../middlewares"
import { validateProductVariantSalesChannelAssociation } from "../../../middlewares/publishable-api-key/validate-variant-sales-channel-association"
import { validateSalesChannelParam } from "../../../middlewares/publishable-api-key/validate-sales-channel-param"
import { PricedVariant } from "../../../../types/pricing"
const route = Router()
@@ -29,6 +29,8 @@ export const defaultStoreVariantRelations = ["prices", "options", "product"]
* - prices
* - options
* - product
* totals:
* - purchasable
* required:
* - variant
* properties:
@@ -48,6 +50,8 @@ export type StoreVariantsRes = {
* - prices
* - options
* - product
* totals:
* - purchasable
* required:
* - variants
* properties:

View File

@@ -1,8 +1,3 @@
import { DbAwareColumn, generateEntityId } from "../utils"
import { MoneyAmount } from "./money-amount"
import { Product } from "./product"
import { ProductOptionValue } from "./product-option-value"
import { SoftDeletableEntity } from "../interfaces"
import {
BeforeInsert,
Column,
@@ -10,10 +5,15 @@ import {
Index,
JoinColumn,
ManyToOne,
OneToMany
OneToMany,
} from "typeorm"
import { DbAwareColumn, generateEntityId } from "../utils"
import { MoneyAmount } from "./money-amount"
import { Product } from "./product"
import { ProductOptionValue } from "./product-option-value"
import { ProductVariantInventoryItem } from "./product-variant-inventory-item"
import { SoftDeletableEntity } from "../interfaces"
@Entity()
export class ProductVariant extends SoftDeletableEntity {
@@ -103,6 +103,8 @@ export class ProductVariant extends SoftDeletableEntity {
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: Record<string, unknown>
purchasable?: boolean
@BeforeInsert()
private beforeInsert(): void {
this.id = generateEntityId(this.id, "variant")
@@ -264,4 +266,14 @@ export class ProductVariant extends SoftDeletableEntity {
* nullable: true
* type: object
* example: {car: "white"}
* purchasable:
* description: |
* Only used with the inventory modules.
* A boolean value indicating whether the Product Variant is purchasable.
* A variant is purchasable if:
* - inventory is not managed
* - it has no inventory items
* - it is in stock
* - it is backorderable.
* type: boolean
*/

View File

@@ -638,28 +638,50 @@ class ProductVariantInventoryService extends TransactionBaseService {
async setVariantAvailability(
variants: ProductVariant[] | PricedVariant[],
salesChannelId: string | string[] | undefined
salesChannelId: string | string[] | undefined,
variantInventoryMap: Map<string, ProductVariantInventoryItem[]> = new Map()
): Promise<ProductVariant[] | PricedVariant[]> {
if (!this.inventoryService_) {
return variants
}
if (!variantInventoryMap.size) {
const variantInventories = await this.listByVariant(
variants.map((v) => v.id)
)
variantInventories.forEach((inventory) => {
const variantId = inventory.variant_id
const currentInventories = variantInventoryMap.get(variantId) || []
currentInventories.push(inventory)
variantInventoryMap.set(variantId, currentInventories)
})
}
return await Promise.all(
variants.map(async (variant) => {
if (!variant.id) {
return variant
}
if (!salesChannelId) {
delete variant.inventory_quantity
variant.purchasable = variant.allow_backorder
if (!variant.manage_inventory) {
variant.purchasable = true
return variant
}
// first get all inventory items required for a variant
const variantInventory = await this.listByVariant(variant.id)
const variantInventory = variantInventoryMap.get(variant.id) || []
if (!variantInventory.length) {
variant.inventory_quantity = 0
delete variant.inventory_quantity
variant.purchasable = true
return variant
}
if (!salesChannelId) {
delete variant.inventory_quantity
variant.purchasable = false
return variant
}
@@ -678,6 +700,9 @@ class ProductVariantInventoryService extends TransactionBaseService {
0
)
variant.purchasable =
variant.inventory_quantity > 0 || variant.allow_backorder
return variant
})
)

View File

@@ -1,5 +1,6 @@
import { PriceSelectionContext } from "../interfaces/price-selection-strategy"
import { MoneyAmount, Product, ProductVariant, ShippingOption } from "../models"
import { PriceSelectionContext } from "../interfaces/price-selection-strategy"
import { TaxServiceRate } from "./tax-service"
export type ProductVariantPricing = {

View File

@@ -1,3 +1,9 @@
import {
DateComparisonOperator,
NumericalComparisonOperator,
StringComparisonOperator,
WithRequiredProperty,
} from "./common"
import {
IsBoolean,
IsInt,
@@ -7,15 +13,11 @@ import {
ValidateIf,
ValidateNested,
} from "class-validator"
import { IsType } from "../utils/validators/is-type"
import {
DateComparisonOperator,
NumericalComparisonOperator,
StringComparisonOperator,
WithRequiredProperty,
} from "./common"
import { XorConstraint } from "./validators/xor"
import { PricedVariant } from "./pricing"
import { ProductVariant } from "../models"
import { XorConstraint } from "./validators/xor"
export type ProductVariantPrice = {
id?: string

View File

@@ -1,4 +1,4 @@
import { Transform, Type } from "class-transformer"
import { DateComparisonOperator, FindConfig, Selector } from "./common"
import {
IsArray,
IsBoolean,
@@ -7,21 +7,22 @@ import {
IsString,
ValidateNested,
} from "class-validator"
import SalesChannelFeatureFlag from "../loaders/feature-flags/sales-channels"
import {
PriceList,
Product,
ProductCategory,
ProductOptionValue,
ProductStatus,
SalesChannel,
ProductCategory,
} from "../models"
import { Transform, Type } from "class-transformer"
import { FeatureFlagDecorators } from "../utils/feature-flag-decorators"
import { optionalBooleanMapper } from "../utils/validators/is-boolean"
import { IsType } from "../utils/validators/is-type"
import { DateComparisonOperator, FindConfig, Selector } from "./common"
import { PriceListLoadConfig } from "./price-list"
import { FindOperator } from "typeorm"
import { IsType } from "../utils/validators/is-type"
import { PriceListLoadConfig } from "./price-list"
import SalesChannelFeatureFlag from "../loaders/feature-flags/sales-channels"
import { optionalBooleanMapper } from "../utils/validators/is-boolean"
/**
* API Level DTOs + Validation rules