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:
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/types": patch
|
||||||
|
"@medusajs/medusa": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
feat(medusa,types): product variant store endpoints
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PaginatedResponse } from "../../common"
|
import { PaginatedResponse } from "../../common"
|
||||||
import { StoreProduct } from "../store"
|
import { StoreProduct, StoreProductVariant } from "../store"
|
||||||
|
|
||||||
export interface StoreProductResponse {
|
export interface StoreProductResponse {
|
||||||
/**
|
/**
|
||||||
@@ -14,3 +14,17 @@ export type StoreProductListResponse = PaginatedResponse<{
|
|||||||
*/
|
*/
|
||||||
products: StoreProduct[]
|
products: StoreProduct[]
|
||||||
}>
|
}>
|
||||||
|
|
||||||
|
export interface StoreProductVariantResponse {
|
||||||
|
/**
|
||||||
|
* The product variant's details.
|
||||||
|
*/
|
||||||
|
variant: StoreProductVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StoreProductVariantListResponse = PaginatedResponse<{
|
||||||
|
/**
|
||||||
|
* The list of product variants.
|
||||||
|
*/
|
||||||
|
variants: StoreProductVariant[]
|
||||||
|
}>
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import { storePaymentProvidersMiddlewares } from "./store/payment-providers/midd
|
|||||||
import { storeProductCategoryRoutesMiddlewares } from "./store/product-categories/middlewares"
|
import { storeProductCategoryRoutesMiddlewares } from "./store/product-categories/middlewares"
|
||||||
import { storeProductTagRoutesMiddlewares } from "./store/product-tags/middlewares"
|
import { storeProductTagRoutesMiddlewares } from "./store/product-tags/middlewares"
|
||||||
import { storeProductTypeRoutesMiddlewares } from "./store/product-types/middlewares"
|
import { storeProductTypeRoutesMiddlewares } from "./store/product-types/middlewares"
|
||||||
|
import { storeProductVariantRoutesMiddlewares } from "./store/product-variants/middlewares"
|
||||||
import { storeProductRoutesMiddlewares } from "./store/products/middlewares"
|
import { storeProductRoutesMiddlewares } from "./store/products/middlewares"
|
||||||
import { storeRegionRoutesMiddlewares } from "./store/regions/middlewares"
|
import { storeRegionRoutesMiddlewares } from "./store/regions/middlewares"
|
||||||
import { storeReturnReasonRoutesMiddlewares } from "./store/return-reasons/middlewares"
|
import { storeReturnReasonRoutesMiddlewares } from "./store/return-reasons/middlewares"
|
||||||
@@ -119,6 +120,7 @@ export default defineMiddlewares([
|
|||||||
...adminFulfillmentsRoutesMiddlewares,
|
...adminFulfillmentsRoutesMiddlewares,
|
||||||
...adminFulfillmentProvidersRoutesMiddlewares,
|
...adminFulfillmentProvidersRoutesMiddlewares,
|
||||||
...storeProductRoutesMiddlewares,
|
...storeProductRoutesMiddlewares,
|
||||||
|
...storeProductVariantRoutesMiddlewares,
|
||||||
...storeReturnReasonRoutesMiddlewares,
|
...storeReturnReasonRoutesMiddlewares,
|
||||||
...adminReturnReasonRoutesMiddlewares,
|
...adminReturnReasonRoutesMiddlewares,
|
||||||
...adminClaimRoutesMiddlewares,
|
...adminClaimRoutesMiddlewares,
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
>
|
||||||
@@ -1,24 +1,17 @@
|
|||||||
import { MedusaStoreRequest, refetchEntity } from "@medusajs/framework/http"
|
import { refetchEntity } from "@medusajs/framework/http"
|
||||||
import {
|
import {
|
||||||
HttpTypes,
|
HttpTypes,
|
||||||
ItemTaxLineDTO,
|
ItemTaxLineDTO,
|
||||||
MedusaContainer,
|
MedusaContainer,
|
||||||
TaxableItemDTO,
|
TaxableItemDTO,
|
||||||
TaxCalculationContext,
|
|
||||||
} from "@medusajs/framework/types"
|
} from "@medusajs/framework/types"
|
||||||
import { calculateAmountsWithTax, Modules } from "@medusajs/framework/utils"
|
import { calculateAmountsWithTax, Modules } from "@medusajs/framework/utils"
|
||||||
|
import { StoreRequestWithContext } from "../types"
|
||||||
|
|
||||||
export type RequestWithContext<
|
export type RequestWithContext<
|
||||||
Body,
|
Body,
|
||||||
QueryFields = Record<string, unknown>
|
QueryFields = Record<string, unknown>
|
||||||
> = MedusaStoreRequest<Body, QueryFields> & {
|
> = StoreRequestWithContext<Body, QueryFields>
|
||||||
taxContext: {
|
|
||||||
taxLineContext?: TaxCalculationContext
|
|
||||||
taxInclusivityContext?: {
|
|
||||||
automaticTaxes: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const refetchProduct = async (
|
export const refetchProduct = async (
|
||||||
idOrFilter: string | object,
|
idOrFilter: string | object,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const DEFAULT_PRICE_FIELD_PATHS = ["variants.calculated_price"]
|
||||||
@@ -5,26 +5,45 @@ import {
|
|||||||
refetchEntities,
|
refetchEntities,
|
||||||
refetchEntity,
|
refetchEntity,
|
||||||
} from "@medusajs/framework/http"
|
} 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) => {
|
return async (req: AuthenticatedMedusaRequest, _, next: NextFunction) => {
|
||||||
// If the product pricing is not requested, we don't need region information
|
// 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
|
let withCalculatedPrice = false
|
||||||
if (calculatedPriceIndex !== -1) {
|
|
||||||
req.queryConfig.fields[calculatedPriceIndex] =
|
req.queryConfig.fields = req.queryConfig.fields.map((field) => {
|
||||||
"variants.calculated_price.*"
|
for (const pricePath of priceFieldPaths) {
|
||||||
withCalculatedPrice = true
|
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.
|
// 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.
|
// TODO: This seems a bit messy, reconsider if we want to keep this logic.
|
||||||
if (!withCalculatedPrice && req.filterableFields.region_id) {
|
if (!withCalculatedPrice && req.filterableFields.region_id) {
|
||||||
req.queryConfig.fields.push("variants.calculated_price.*")
|
for (const pricePath of priceFieldPaths) {
|
||||||
withCalculatedPrice = true
|
const wildcardField = `${pricePath}.*`
|
||||||
|
if (!req.queryConfig.fields.includes(wildcardField)) {
|
||||||
|
req.queryConfig.fields.push(wildcardField)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withCalculatedPrice = priceFieldPaths.length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!withCalculatedPrice) {
|
if (!withCalculatedPrice) {
|
||||||
|
|||||||
@@ -6,11 +6,20 @@ import {
|
|||||||
import { MedusaPricingContext } from "@medusajs/framework/types"
|
import { MedusaPricingContext } from "@medusajs/framework/types"
|
||||||
import { MedusaError } from "@medusajs/framework/utils"
|
import { MedusaError } from "@medusajs/framework/utils"
|
||||||
import { NextFunction } from "express"
|
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) => {
|
return async (req: AuthenticatedMedusaRequest, _, next: NextFunction) => {
|
||||||
const withCalculatedPrice = req.queryConfig.fields.some((field) =>
|
const withCalculatedPrice = req.queryConfig.fields.some((field) =>
|
||||||
field.startsWith("variants.calculated_price")
|
priceFieldPaths.some(
|
||||||
|
(pricePath) => field === pricePath || field.startsWith(`${pricePath}.`)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if (!withCalculatedPrice) {
|
if (!withCalculatedPrice) {
|
||||||
return next()
|
return next()
|
||||||
|
|||||||
@@ -6,12 +6,21 @@ import {
|
|||||||
refetchEntity,
|
refetchEntity,
|
||||||
} from "@medusajs/framework/http"
|
} from "@medusajs/framework/http"
|
||||||
import { MedusaError } from "@medusajs/framework/utils"
|
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) => {
|
return async (req: AuthenticatedMedusaRequest, _, next: NextFunction) => {
|
||||||
const withCalculatedPrice = req.queryConfig.fields.some((field) =>
|
const withCalculatedPrice = req.queryConfig.fields.some((field) =>
|
||||||
field.startsWith("variants.calculated_price")
|
priceFieldPaths.some(
|
||||||
|
(pricePath) => field === pricePath || field.startsWith(`${pricePath}.`)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if (!withCalculatedPrice) {
|
if (!withCalculatedPrice) {
|
||||||
return next()
|
return next()
|
||||||
@@ -26,7 +35,7 @@ export function setTaxContext() {
|
|||||||
const taxLinesContext = await getTaxLinesContext(req)
|
const taxLinesContext = await getTaxLinesContext(req)
|
||||||
|
|
||||||
// TODO: Allow passing a context typings param to AuthenticatedMedusaRequest
|
// 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,
|
taxLineContext: taxLinesContext,
|
||||||
taxInclusivityContext: inclusivity,
|
taxInclusivityContext: inclusivity,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user