feat(medusa,types): product variant store endpoints (#13730)

* wip(medusa): product variant store endpoints

* chore: refactor types

* chore: changesets

* fix: address feedback 1

* feat: load images for variants by default

* fix: use query.graph directly instead of refetchEntity

* feat: enable cache for variants endpoint
This commit is contained in:
Frane Polić
2025-10-28 10:12:07 +01:00
committed by GitHub
parent 4f4ab6208d
commit 25a20ca95f
16 changed files with 901 additions and 29 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/types": patch
"@medusajs/medusa": patch
---
feat(medusa,types): product variant store endpoints

View File

@@ -0,0 +1,390 @@
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { HttpTypes } from "@medusajs/framework/types"
import { IStoreModuleService } from "@medusajs/types"
import { ApiKeyType, Modules, ProductStatus } from "@medusajs/utils"
import {
adminHeaders,
createAdminUser,
generatePublishableKey,
generateStoreHeaders,
} from "../../../../helpers/create-admin-user"
import { getProductFixture } from "../../../../helpers/fixtures"
jest.setTimeout(60000)
medusaIntegrationTestRunner({
testSuite: ({ dbConnection, api, getContainer }) => {
let appContainer
let publishableKey
let storeHeaders
let store
let region
let shippingProfile
const createProduct = async (payload: HttpTypes.AdminCreateProduct) => {
const response = await api.post(
"/admin/products?fields=*variants",
payload,
adminHeaders
)
return [response.data.product, response.data.product.variants || []]
}
const createSalesChannel = async (
data: HttpTypes.AdminCreateSalesChannel,
productIds: string[] = []
) => {
const response = await api.post(
"/admin/sales-channels",
data,
adminHeaders
)
const salesChannel = response.data.sales_channel
if (productIds?.length) {
await api.post(
`/admin/sales-channels/${salesChannel.id}/products`,
{ add: productIds },
adminHeaders
)
}
return salesChannel
}
beforeEach(async () => {
appContainer = getContainer()
publishableKey = await generatePublishableKey(appContainer)
storeHeaders = generateStoreHeaders({ publishableKey })
await createAdminUser(dbConnection, adminHeaders, appContainer)
const storeModule: IStoreModuleService = appContainer.resolve(
Modules.STORE
)
const defaultStoreId = (await api.get("/admin/stores", adminHeaders)).data
.stores?.[0]?.id
if (defaultStoreId) {
await storeModule.deleteStores(defaultStoreId)
}
store = await storeModule.createStores({
name: "Store",
supported_currencies: [
{ currency_code: "usd", is_default: true },
{ currency_code: "eur" },
],
})
region = (
await api.post(
"/admin/regions",
{ name: "Test Region", currency_code: "usd" },
adminHeaders
)
).data.region
shippingProfile = (
await api.post(
`/admin/shipping-profiles`,
{ name: "default", type: "default" },
adminHeaders
)
).data.shipping_profile
})
describe("GET /store/product-variants", () => {
let product1
let product2
let variant1
let variant2
let salesChannel1
let salesChannel2
beforeEach(async () => {
;[product1, [variant1]] = await createProduct(
getProductFixture({
title: "Variant product 1",
status: ProductStatus.PUBLISHED,
shipping_profile_id: shippingProfile.id,
})
)
;[product2, [variant2]] = await createProduct(
getProductFixture({
title: "Variant product 2",
status: ProductStatus.PUBLISHED,
shipping_profile_id: shippingProfile.id,
})
)
salesChannel1 = await createSalesChannel(
{ name: "sales channel one" },
[product1.id]
)
salesChannel2 = await createSalesChannel(
{ name: "sales channel two" },
[product2.id]
)
await api.post(
`/admin/stores/${store.id}`,
{ default_sales_channel_id: salesChannel1.id },
adminHeaders
)
})
it("returns variants associated with the publishable key sales channel", async () => {
await api.post(
`/admin/api-keys/${publishableKey.id}/sales-channels`,
{ add: [salesChannel1.id] },
adminHeaders
)
const response = await api.get("/store/product-variants", storeHeaders)
expect(response.status).toEqual(200)
expect(response.data.count).toEqual(1)
expect(response.data.variants).toEqual([
expect.objectContaining({
id: variant1.id,
product_id: product1.id,
}),
])
})
it("allows overriding the sales channel when it is within publishable key scope", async () => {
await api.post(
`/admin/api-keys/${publishableKey.id}/sales-channels`,
{ add: [salesChannel1.id, salesChannel2.id] },
adminHeaders
)
const response = await api.get(
`/store/product-variants?sales_channel_id[]=${salesChannel2.id}`,
storeHeaders
)
expect(response.status).toEqual(200)
expect(response.data.count).toEqual(1)
expect(response.data.variants).toEqual([
expect.objectContaining({
id: variant2.id,
product_id: product2.id,
}),
])
})
it("throws when filtering by a sales channel outside publishable key scope", async () => {
await api.post(
`/admin/api-keys/${publishableKey.id}/sales-channels`,
{ add: [salesChannel1.id] },
adminHeaders
)
const error = await api
.get(
`/store/product-variants?sales_channel_id[]=${salesChannel2.id}`,
storeHeaders
)
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toEqual(
"Requested sales channel is not part of the publishable key"
)
})
})
describe("GET /store/product-variants/:id", () => {
let product1
let product2
let variant1
let variant2
let salesChannel1
let salesChannel2
beforeEach(async () => {
;[product1, [variant1]] = await createProduct(
getProductFixture({
title: "Variant product 1",
status: ProductStatus.PUBLISHED,
shipping_profile_id: shippingProfile.id,
})
)
;[product2, [variant2]] = await createProduct(
getProductFixture({
title: "Variant product 2",
status: ProductStatus.PUBLISHED,
shipping_profile_id: shippingProfile.id,
})
)
salesChannel1 = await createSalesChannel(
{ name: "sales channel one" },
[product1.id]
)
salesChannel2 = await createSalesChannel(
{ name: "sales channel two" },
[product2.id]
)
await api.post(
`/admin/api-keys/${publishableKey.id}/sales-channels`,
{ add: [salesChannel1.id] },
adminHeaders
)
})
it("retrieves a variant available to the publishable key", async () => {
const response = await api.get(
`/store/product-variants/${variant1.id}`,
storeHeaders
)
expect(response.status).toEqual(200)
expect(response.data.variant).toEqual(
expect.objectContaining({
id: variant1.id,
product_id: product1.id,
})
)
})
it("returns 404 when the variant is not available in the publishable key scope", async () => {
const error = await api
.get(`/store/product-variants/${variant2.id}`, storeHeaders)
.catch((e) => e)
expect(error.response.status).toEqual(404)
expect(error.response.data.message).toEqual(
`Product variant with id: ${variant2.id} was not found`
)
})
it("returns 404 when the variant does not exist", async () => {
const error = await api
.get(`/store/product-variants/not-real`, storeHeaders)
.catch((e) => e)
expect(error.response.status).toEqual(404)
expect(error.response.data.message).toEqual(
"Product variant with id: not-real was not found"
)
})
it("returns calculated price data when requested", async () => {
const response = await api.get(
`/store/product-variants/${variant1.id}?region_id=${region.id}&fields=calculated_price`,
storeHeaders
)
expect(response.status).toEqual(200)
expect(response.data.variant).toEqual(
expect.objectContaining({
id: variant1.id,
calculated_price: expect.objectContaining({
calculated_amount: expect.any(Number),
currency_code: "usd",
}),
})
)
})
})
describe("GET /store/product-variants inventory quantities", () => {
it("returns inventory quantity scoped to publishable key sales channel", async () => {
const container = getContainer()
const channelService = container.resolve("sales_channel")
const locationService = container.resolve("stock_location")
const inventoryService = container.resolve("inventory")
const productService = container.resolve("product")
const pubKeyService = container.resolve("api_key")
const linkService = container.resolve("remoteLink")
const [channel] = await channelService.createSalesChannels([
{ name: "PK Sales Channel" },
])
const product = await productService.createProducts({
status: ProductStatus.PUBLISHED,
title: "inventory product",
options: [{ title: "size", values: ["large"] }],
variants: [
{
title: "inv variant",
options: { size: "large" },
},
],
})
const [variant] = product.variants
const [inventoryItem] = await inventoryService.createInventoryItems([
{ sku: "inv-sku" },
])
const [location] = await locationService.createStockLocations([
{ name: "Warehouse" },
])
await inventoryService.createInventoryLevels([
{
location_id: location.id,
inventory_item_id: inventoryItem.id,
stocked_quantity: 10,
},
])
const [pk] = await pubKeyService.createApiKeys([
{
title: "Variant PK",
type: ApiKeyType.PUBLISHABLE,
created_by: "test",
},
])
await linkService.create([
{
product: { product_id: product.id },
sales_channel: { sales_channel_id: channel.id },
},
{
sales_channel: { sales_channel_id: channel.id },
stock_location: { stock_location_id: location.id },
},
{
product: { variant_id: variant.id },
inventory: { inventory_item_id: inventoryItem.id },
},
{
api_key: { publishable_key_id: pk.id },
sales_channel: { sales_channel_id: channel.id },
},
])
const response = await api.get(
`/store/product-variants?fields=+inventory_quantity`,
{
headers: {
"x-publishable-api-key": pk.token,
},
}
)
expect(response.status).toEqual(200)
expect(response.data.variants).toEqual([
expect.objectContaining({
id: variant.id,
inventory_quantity: 10,
}),
])
})
})
},
})

View File

@@ -1,5 +1,5 @@
import { PaginatedResponse } from "../../common"
import { StoreProduct } from "../store"
import { StoreProduct, StoreProductVariant } from "../store"
export interface StoreProductResponse {
/**
@@ -14,3 +14,17 @@ export type StoreProductListResponse = PaginatedResponse<{
*/
products: StoreProduct[]
}>
export interface StoreProductVariantResponse {
/**
* The product variant's details.
*/
variant: StoreProductVariant
}
export type StoreProductVariantListResponse = PaginatedResponse<{
/**
* The list of product variants.
*/
variants: StoreProductVariant[]
}>

View File

@@ -58,6 +58,7 @@ import { storePaymentProvidersMiddlewares } from "./store/payment-providers/midd
import { storeProductCategoryRoutesMiddlewares } from "./store/product-categories/middlewares"
import { storeProductTagRoutesMiddlewares } from "./store/product-tags/middlewares"
import { storeProductTypeRoutesMiddlewares } from "./store/product-types/middlewares"
import { storeProductVariantRoutesMiddlewares } from "./store/product-variants/middlewares"
import { storeProductRoutesMiddlewares } from "./store/products/middlewares"
import { storeRegionRoutesMiddlewares } from "./store/regions/middlewares"
import { storeReturnReasonRoutesMiddlewares } from "./store/return-reasons/middlewares"
@@ -119,6 +120,7 @@ export default defineMiddlewares([
...adminFulfillmentsRoutesMiddlewares,
...adminFulfillmentProvidersRoutesMiddlewares,
...storeProductRoutesMiddlewares,
...storeProductVariantRoutesMiddlewares,
...storeReturnReasonRoutesMiddlewares,
...adminReturnReasonRoutesMiddlewares,
...adminClaimRoutesMiddlewares,

View File

@@ -0,0 +1,67 @@
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import { HttpTypes, QueryContextType } from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
MedusaError,
QueryContext,
} from "@medusajs/framework/utils"
import { wrapVariantsWithInventoryQuantityForSalesChannel } from "../../../utils/middlewares"
import { StoreRequestWithContext } from "../../types"
import { wrapVariantsWithTaxPrices } from "../helpers"
import { StoreProductVariantParamsType } from "../validators"
type StoreVariantRetrieveRequest =
StoreRequestWithContext<HttpTypes.StoreProductVariantParams> &
AuthenticatedMedusaRequest<StoreProductVariantParamsType>
export const GET = async (
req: StoreVariantRetrieveRequest,
res: MedusaResponse<HttpTypes.StoreProductVariantResponse>
) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const withInventoryQuantity =
req.queryConfig.fields.includes("inventory_quantity")
if (withInventoryQuantity) {
req.queryConfig.fields = req.queryConfig.fields.filter(
(field) => field !== "inventory_quantity"
)
}
const context: QueryContextType = {}
if (req.pricingContext) {
context["calculated_price"] = QueryContext(req.pricingContext)
}
const { data: variants = [] } = await query.graph({
entity: "variant",
filters: {
...req.filterableFields,
id: req.params.id,
},
fields: req.queryConfig.fields,
context,
})
const variant = variants[0]
if (!variant) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Product variant with id: ${req.params.id} was not found`
)
}
if (withInventoryQuantity) {
await wrapVariantsWithInventoryQuantityForSalesChannel(req, [variant])
}
await wrapVariantsWithTaxPrices(req, [variant])
res.json({ variant })
}

View File

@@ -0,0 +1,98 @@
import {
HttpTypes,
ItemTaxLineDTO,
TaxableItemDTO,
} from "@medusajs/framework/types"
import { calculateAmountsWithTax, Modules } from "@medusajs/framework/utils"
import { StoreRequestWithContext } from "../types"
export const wrapVariantsWithTaxPrices = async <T>(
req: StoreRequestWithContext<T>,
variants: HttpTypes.StoreProductVariant[]
) => {
if (
!req.taxContext?.taxInclusivityContext ||
!req.taxContext?.taxLineContext
) {
return
}
if (!variants?.length) {
return
}
const items = variants
.map(asTaxItem)
.filter((item) => !!item) as TaxableItemDTO[]
if (!items.length) {
return
}
const taxService = req.scope.resolve(Modules.TAX)
const taxLines = (await taxService.getTaxLines(
items,
req.taxContext.taxLineContext
)) as unknown as ItemTaxLineDTO[]
const taxRatesMap = new Map<string, ItemTaxLineDTO[]>()
taxLines.forEach((taxLine) => {
if (!taxRatesMap.has(taxLine.line_item_id)) {
taxRatesMap.set(taxLine.line_item_id, [])
}
taxRatesMap.get(taxLine.line_item_id)!.push(taxLine)
})
variants.forEach((variant) => {
if (!variant.calculated_price) {
return
}
const taxRatesForVariant = taxRatesMap.get(variant.id) || []
const { priceWithTax, priceWithoutTax } = calculateAmountsWithTax({
taxLines: taxRatesForVariant,
amount: variant.calculated_price.calculated_amount!,
includesTax: variant.calculated_price.is_calculated_price_tax_inclusive!,
})
variant.calculated_price.calculated_amount_with_tax = priceWithTax
variant.calculated_price.calculated_amount_without_tax = priceWithoutTax
const {
priceWithTax: originalPriceWithTax,
priceWithoutTax: originalPriceWithoutTax,
} = calculateAmountsWithTax({
taxLines: taxRatesForVariant,
amount: variant.calculated_price.original_amount!,
includesTax: variant.calculated_price.is_original_price_tax_inclusive!,
})
variant.calculated_price.original_amount_with_tax = originalPriceWithTax
variant.calculated_price.original_amount_without_tax =
originalPriceWithoutTax
})
}
const asTaxItem = (variant: HttpTypes.StoreProductVariant) => {
if (!variant.calculated_price) {
return
}
const productId = variant.product_id ?? variant.product?.id
if (!productId) {
return
}
return {
id: variant.id,
product_id: productId,
product_type_id: variant.product?.type_id ?? undefined,
quantity: 1,
unit_price: variant.calculated_price.calculated_amount,
currency_code: variant.calculated_price.currency_code,
}
}

View File

@@ -0,0 +1,85 @@
import { validateAndTransformQuery } from "@medusajs/framework"
import {
applyDefaultFilters,
applyParamsAsFilters,
authenticate,
clearFiltersByKey,
maybeApplyLinkFilter,
MiddlewareRoute,
} from "@medusajs/framework/http"
import { ProductStatus } from "@medusajs/framework/utils"
import {
filterByValidSalesChannels,
normalizeDataForContext,
setPricingContext,
setTaxContext,
} from "../../utils/middlewares"
import * as QueryConfig from "./query-config"
import {
StoreProductVariantListParams,
StoreProductVariantParams,
} from "./validators"
const pricingMiddlewares = [
normalizeDataForContext({ priceFieldPaths: ["calculated_price"] }),
setPricingContext({ priceFieldPaths: ["calculated_price"] }),
setTaxContext({ priceFieldPaths: ["calculated_price"] }),
]
export const storeProductVariantRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["GET"],
matcher: "/store/product-variants",
middlewares: [
authenticate("customer", ["session", "bearer"], {
allowUnauthenticated: true,
}),
validateAndTransformQuery(
StoreProductVariantListParams,
QueryConfig.listProductVariantConfig
),
filterByValidSalesChannels(),
maybeApplyLinkFilter({
entryPoint: "product_sales_channel",
resourceId: "product_id",
filterableField: "sales_channel_id",
filterByField: "product.id",
}),
applyDefaultFilters({
product: {
status: ProductStatus.PUBLISHED,
},
}),
...pricingMiddlewares,
clearFiltersByKey(["region_id", "country_code", "province", "cart_id"]),
],
},
{
method: ["GET"],
matcher: "/store/product-variants/:id",
middlewares: [
authenticate("customer", ["session", "bearer"], {
allowUnauthenticated: true,
}),
validateAndTransformQuery(
StoreProductVariantParams,
QueryConfig.retrieveProductVariantConfig
),
applyParamsAsFilters({ id: "id" }),
filterByValidSalesChannels(),
maybeApplyLinkFilter({
entryPoint: "product_sales_channel",
resourceId: "product_id",
filterableField: "sales_channel_id",
filterByField: "product.id",
}),
applyDefaultFilters({
product: {
status: ProductStatus.PUBLISHED,
},
}),
...pricingMiddlewares,
clearFiltersByKey(["region_id", "country_code", "province", "cart_id"]),
],
},
]

View File

@@ -0,0 +1,39 @@
export const defaultStoreProductVariantFields = [
"id",
"title",
"sku",
"barcode",
"ean",
"upc",
"allow_backorder",
"manage_inventory",
"variant_rank",
"product_id",
"thumbnail",
"hs_code",
"origin_country",
"mid_code",
"material",
"weight",
"length",
"height",
"width",
"created_at",
"updated_at",
"metadata",
"*options",
"*images",
"product.id",
"product.type_id",
]
export const retrieveProductVariantConfig = {
defaults: defaultStoreProductVariantFields,
isList: false,
}
export const listProductVariantConfig = {
...retrieveProductVariantConfig,
defaultLimit: 20,
isList: true,
}

View File

@@ -0,0 +1,66 @@
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http"
import { HttpTypes, QueryContextType } from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
QueryContext,
} from "@medusajs/framework/utils"
import { wrapVariantsWithInventoryQuantityForSalesChannel } from "../../utils/middlewares"
import { StoreRequestWithContext } from "../types"
import { wrapVariantsWithTaxPrices } from "./helpers"
type StoreVariantListRequest =
StoreRequestWithContext<HttpTypes.StoreProductVariantParams> &
AuthenticatedMedusaRequest<HttpTypes.StoreProductVariantParams>
export const GET = async (
req: StoreVariantListRequest,
res: MedusaResponse<HttpTypes.StoreProductVariantListResponse>
) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const withInventoryQuantity =
req.queryConfig.fields.includes("inventory_quantity")
if (withInventoryQuantity) {
req.queryConfig.fields = req.queryConfig.fields.filter(
(field) => field !== "inventory_quantity"
)
}
const context: QueryContextType = {}
if (req.pricingContext) {
context["calculated_price"] = QueryContext(req.pricingContext)
}
const { data: variants = [], metadata } = await query.graph(
{
entity: "variant",
fields: req.queryConfig.fields,
filters: req.filterableFields,
pagination: req.queryConfig.pagination,
context,
},
{
cache: {
enable: true,
},
}
)
if (withInventoryQuantity) {
await wrapVariantsWithInventoryQuantityForSalesChannel(req, variants)
}
await wrapVariantsWithTaxPrices(req, variants)
res.json({
variants,
count: metadata?.count ?? 0,
offset: metadata?.skip ?? 0,
limit: metadata?.take ?? 0,
})
}

View File

@@ -0,0 +1,56 @@
import { z } from "zod"
import {
applyAndAndOrOperators,
booleanString,
} from "../../utils/common-validators"
import {
createFindParams,
createOperatorMap,
createSelectParams,
} from "../../utils/validators"
const StoreProductVariantContextFields = z.object({
region_id: z.string().optional(),
country_code: z.string().optional(),
province: z.string().optional(),
cart_id: z.string().optional(),
sales_channel_id: z.union([z.string(), z.array(z.string())]).optional(),
})
const StoreProductVariantFilterFields = z.object({
q: z.string().optional(),
id: z.union([z.string(), z.array(z.string())]).optional(),
sku: z.union([z.string(), z.array(z.string())]).optional(),
product_id: z.union([z.string(), z.array(z.string())]).optional(),
options: z
.object({
value: z.string().optional(),
option_id: z.string().optional(),
})
.optional(),
allow_backorder: booleanString().optional(),
manage_inventory: booleanString().optional(),
created_at: createOperatorMap().optional(),
updated_at: createOperatorMap().optional(),
deleted_at: createOperatorMap().optional(),
})
export const StoreProductVariantParams = createSelectParams().merge(
StoreProductVariantContextFields
)
export type StoreProductVariantParamsType = z.infer<
typeof StoreProductVariantParams
>
export const StoreProductVariantListParams = createFindParams({
offset: 0,
limit: 20,
})
.merge(StoreProductVariantContextFields)
.merge(StoreProductVariantFilterFields)
.merge(applyAndAndOrOperators(StoreProductVariantFilterFields))
export type StoreProductVariantListParamsType = z.infer<
typeof StoreProductVariantListParams
>

View File

@@ -1,24 +1,17 @@
import { MedusaStoreRequest, refetchEntity } from "@medusajs/framework/http"
import { refetchEntity } from "@medusajs/framework/http"
import {
HttpTypes,
ItemTaxLineDTO,
MedusaContainer,
TaxableItemDTO,
TaxCalculationContext,
} from "@medusajs/framework/types"
import { calculateAmountsWithTax, Modules } from "@medusajs/framework/utils"
import { StoreRequestWithContext } from "../types"
export type RequestWithContext<
Body,
QueryFields = Record<string, unknown>
> = MedusaStoreRequest<Body, QueryFields> & {
taxContext: {
taxLineContext?: TaxCalculationContext
taxInclusivityContext?: {
automaticTaxes: boolean
}
}
}
> = StoreRequestWithContext<Body, QueryFields>
export const refetchProduct = async (
idOrFilter: string | object,

View File

@@ -0,0 +1,18 @@
import { MedusaStoreRequest } from "@medusajs/framework/http"
import {
MedusaPricingContext,
TaxCalculationContext,
} from "@medusajs/framework/types"
export type StoreRequestWithContext<
Body,
QueryFields = Record<string, unknown>
> = MedusaStoreRequest<Body, QueryFields> & {
pricingContext?: MedusaPricingContext
taxContext?: {
taxLineContext?: TaxCalculationContext
taxInclusivityContext?: {
automaticTaxes: boolean
}
}
}

View File

@@ -0,0 +1 @@
export const DEFAULT_PRICE_FIELD_PATHS = ["variants.calculated_price"]

View File

@@ -5,26 +5,45 @@ import {
refetchEntities,
refetchEntity,
} from "@medusajs/framework/http"
import { DEFAULT_PRICE_FIELD_PATHS } from "./constants"
type PricingContextOptions = {
priceFieldPaths?: string[]
}
export function normalizeDataForContext(options: PricingContextOptions = {}) {
const { priceFieldPaths = DEFAULT_PRICE_FIELD_PATHS } = options
export function normalizeDataForContext() {
return async (req: AuthenticatedMedusaRequest, _, next: NextFunction) => {
// If the product pricing is not requested, we don't need region information
const calculatedPriceIndex = req.queryConfig.fields.findIndex((field) =>
field.startsWith("variants.calculated_price")
)
let withCalculatedPrice = false
if (calculatedPriceIndex !== -1) {
req.queryConfig.fields[calculatedPriceIndex] =
"variants.calculated_price.*"
withCalculatedPrice = true
}
req.queryConfig.fields = req.queryConfig.fields.map((field) => {
for (const pricePath of priceFieldPaths) {
if (field === pricePath) {
withCalculatedPrice = true
return `${pricePath}.*`
}
if (field.startsWith(`${pricePath}.`)) {
withCalculatedPrice = true
return field
}
}
return field
})
// If the region is passed, we calculate the prices without requesting them.
// TODO: This seems a bit messy, reconsider if we want to keep this logic.
if (!withCalculatedPrice && req.filterableFields.region_id) {
req.queryConfig.fields.push("variants.calculated_price.*")
withCalculatedPrice = true
for (const pricePath of priceFieldPaths) {
const wildcardField = `${pricePath}.*`
if (!req.queryConfig.fields.includes(wildcardField)) {
req.queryConfig.fields.push(wildcardField)
}
}
withCalculatedPrice = priceFieldPaths.length > 0
}
if (!withCalculatedPrice) {

View File

@@ -6,11 +6,20 @@ import {
import { MedusaPricingContext } from "@medusajs/framework/types"
import { MedusaError } from "@medusajs/framework/utils"
import { NextFunction } from "express"
import { DEFAULT_PRICE_FIELD_PATHS } from "./constants"
type PricingContextOptions = {
priceFieldPaths?: string[]
}
export function setPricingContext(options: PricingContextOptions = {}) {
const { priceFieldPaths = DEFAULT_PRICE_FIELD_PATHS } = options
export function setPricingContext() {
return async (req: AuthenticatedMedusaRequest, _, next: NextFunction) => {
const withCalculatedPrice = req.queryConfig.fields.some((field) =>
field.startsWith("variants.calculated_price")
priceFieldPaths.some(
(pricePath) => field === pricePath || field.startsWith(`${pricePath}.`)
)
)
if (!withCalculatedPrice) {
return next()

View File

@@ -6,12 +6,21 @@ import {
refetchEntity,
} from "@medusajs/framework/http"
import { MedusaError } from "@medusajs/framework/utils"
import { RequestWithContext } from "../../../store/products/helpers"
import { StoreRequestWithContext } from "../../../store/types"
import { DEFAULT_PRICE_FIELD_PATHS } from "./constants"
type TaxContextOptions = {
priceFieldPaths?: string[]
}
export function setTaxContext(options: TaxContextOptions = {}) {
const { priceFieldPaths = DEFAULT_PRICE_FIELD_PATHS } = options
export function setTaxContext() {
return async (req: AuthenticatedMedusaRequest, _, next: NextFunction) => {
const withCalculatedPrice = req.queryConfig.fields.some((field) =>
field.startsWith("variants.calculated_price")
priceFieldPaths.some(
(pricePath) => field === pricePath || field.startsWith(`${pricePath}.`)
)
)
if (!withCalculatedPrice) {
return next()
@@ -26,7 +35,7 @@ export function setTaxContext() {
const taxLinesContext = await getTaxLinesContext(req)
// TODO: Allow passing a context typings param to AuthenticatedMedusaRequest
;(req as unknown as RequestWithContext<any>).taxContext = {
;(req as unknown as StoreRequestWithContext<any>).taxContext = {
taxLineContext: taxLinesContext,
taxInclusivityContext: inclusivity,
}