feat: Add necessary middlewares for tax inclusive pricing (#7827)
We are adding tax inclusive pricing calculation when listing products. Two things to keep in mind: - `region_id` will be required if you request calculated prices. - We won't accept `currency_code` anymore, as that will come from the region info (since ultimately a cart and its currency are tied to a region) REF CORE-2376 DEPENDS ON #8003
This commit is contained in:
1 parent
db6969578f
commit
1c3ef13371
22 files changed
+824
-121
No files matched your search
@@ -1,11 +1,15 @@
|
||||
import { MedusaError, isPresent } from "@medusajs/utils"
|
||||
import { MedusaRequest, MedusaResponse } from "../../../../types/routing"
|
||||
import { MedusaResponse } from "../../../../types/routing"
|
||||
import { wrapVariantsWithInventoryQuantity } from "../../../utils/middlewares"
|
||||
import { refetchProduct } from "../helpers"
|
||||
import { StoreGetProductsParamsType } from "../validators"
|
||||
import {
|
||||
RequestWithContext,
|
||||
refetchProduct,
|
||||
wrapProductsWithTaxPrices,
|
||||
} from "../helpers"
|
||||
import { StoreGetProductParamsType } from "../validators"
|
||||
|
||||
export const GET = async (
|
||||
req: MedusaRequest<StoreGetProductsParamsType>,
|
||||
req: RequestWithContext<StoreGetProductParamsType>,
|
||||
res: MedusaResponse
|
||||
) => {
|
||||
const withInventoryQuantity = req.remoteQueryConfig.fields.some((field) =>
|
||||
@@ -46,5 +50,6 @@ export const GET = async (
|
||||
await wrapVariantsWithInventoryQuantity(req, product.variants || [])
|
||||
}
|
||||
|
||||
await wrapProductsWithTaxPrices(req, [product])
|
||||
res.json({ product })
|
||||
}
|
||||
@@ -1,6 +1,25 @@
|
||||
import { MedusaContainer } from "@medusajs/types"
|
||||
import {
|
||||
ModuleRegistrationName,
|
||||
calculateAmountsWithTax,
|
||||
} from "@medusajs/utils"
|
||||
import { MedusaRequest } from "../../../types/routing"
|
||||
import { refetchEntities, refetchEntity } from "../../utils/refetch-entity"
|
||||
import {
|
||||
MedusaContainer,
|
||||
HttpTypes,
|
||||
TaxableItemDTO,
|
||||
ItemTaxLineDTO,
|
||||
TaxCalculationContext,
|
||||
} from "@medusajs/types"
|
||||
|
||||
export type RequestWithContext<T> = MedusaRequest<T> & {
|
||||
taxContext: {
|
||||
taxLineContext?: TaxCalculationContext
|
||||
taxInclusivityContext?: {
|
||||
automaticTaxes: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const refetchProduct = async (
|
||||
idOrFilter: string | object,
|
||||
@@ -30,3 +49,81 @@ export const maybeApplyStockLocationId = async (req: MedusaRequest, ctx) => {
|
||||
|
||||
return entities.map((entity) => entity.stock_location_id)
|
||||
}
|
||||
|
||||
export const wrapProductsWithTaxPrices = async <T>(
|
||||
req: RequestWithContext<T>,
|
||||
products: HttpTypes.StoreProduct[]
|
||||
) => {
|
||||
// If we are missing the necessary context, we can't calculate the tax, so only `calculated_amount` will be available
|
||||
if (
|
||||
!req.taxContext?.taxInclusivityContext ||
|
||||
!req.taxContext?.taxLineContext
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// If automatic taxes are not enabled, we should skip calculating any tax
|
||||
if (!req.taxContext.taxInclusivityContext.automaticTaxes) {
|
||||
return
|
||||
}
|
||||
|
||||
const taxService = req.scope.resolve(ModuleRegistrationName.TAX)
|
||||
|
||||
const taxRates = (await taxService.getTaxLines(
|
||||
products.map(asTaxItem).flat(),
|
||||
req.taxContext.taxLineContext
|
||||
)) as unknown as ItemTaxLineDTO[]
|
||||
|
||||
const taxRatesMap = new Map<string, ItemTaxLineDTO[]>()
|
||||
taxRates.forEach((taxRate) => {
|
||||
if (!taxRatesMap.has(taxRate.line_item_id)) {
|
||||
taxRatesMap.set(taxRate.line_item_id, [])
|
||||
}
|
||||
|
||||
taxRatesMap.get(taxRate.line_item_id)?.push(taxRate)
|
||||
})
|
||||
|
||||
products.forEach((product) => {
|
||||
product.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 asTaxItem = (product: HttpTypes.StoreProduct): TaxableItemDTO[] => {
|
||||
return product.variants
|
||||
?.map((variant) => {
|
||||
if (!variant.calculated_price) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
id: variant.id,
|
||||
product_id: product.id,
|
||||
product_name: product.title,
|
||||
product_categories: product.categories?.map((c) => c.name),
|
||||
// TODO: It is strange that we only accept a single category, revisit the tax module implementation
|
||||
product_category_id: product.categories?.[0]?.id,
|
||||
product_sku: variant.sku,
|
||||
product_type: product.type,
|
||||
product_type_id: product.type_id,
|
||||
quantity: 1,
|
||||
unit_price: variant.calculated_price.calculated_amount,
|
||||
currency_code: variant.calculated_price.currency_code,
|
||||
}
|
||||
})
|
||||
.filter((v) => !!v) as unknown as TaxableItemDTO[]
|
||||
}
|
||||
@@ -3,8 +3,11 @@ import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
|
||||
import { maybeApplyLinkFilter } from "../../utils/maybe-apply-link-filter"
|
||||
import {
|
||||
applyDefaultFilters,
|
||||
clearFiltersByKey,
|
||||
filterByValidSalesChannels,
|
||||
normalizeDataForContext,
|
||||
setPricingContext,
|
||||
setTaxContext,
|
||||
} from "../../utils/middlewares"
|
||||
import { setContext } from "../../utils/middlewares/common/set-context"
|
||||
import { validateAndTransformQuery } from "../../utils/validate-query"
|
||||
@@ -47,7 +50,10 @@ export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [
|
||||
return { id: categoryIds, is_internal: false, is_active: true }
|
||||
},
|
||||
}),
|
||||
normalizeDataForContext(),
|
||||
setPricingContext(),
|
||||
setTaxContext(),
|
||||
clearFiltersByKey(["region_id", "country_code", "province", "cart_id"]),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -78,7 +84,10 @@ export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [
|
||||
return { is_internal: false, is_active: true }
|
||||
},
|
||||
}),
|
||||
normalizeDataForContext(),
|
||||
setPricingContext(),
|
||||
setTaxContext(),
|
||||
clearFiltersByKey(["region_id", "country_code", "province", "cart_id"]),
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -3,12 +3,13 @@ import {
|
||||
isPresent,
|
||||
remoteQueryObjectFromString,
|
||||
} from "@medusajs/utils"
|
||||
import { MedusaRequest, MedusaResponse } from "../../../types/routing"
|
||||
import { MedusaResponse } from "../../../types/routing"
|
||||
import { wrapVariantsWithInventoryQuantity } from "../../utils/middlewares"
|
||||
import { StoreGetProductsParamsType } from "./validators"
|
||||
import { RequestWithContext, wrapProductsWithTaxPrices } from "./helpers"
|
||||
|
||||
export const GET = async (
|
||||
req: MedusaRequest<StoreGetProductsParamsType>,
|
||||
req: RequestWithContext<StoreGetProductsParamsType>,
|
||||
res: MedusaResponse
|
||||
) => {
|
||||
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
||||
@@ -48,6 +49,7 @@ export const GET = async (
|
||||
)
|
||||
}
|
||||
|
||||
await wrapProductsWithTaxPrices(req, products)
|
||||
res.json({
|
||||
products,
|
||||
count: metadata.count,
|
||||
|
||||
@@ -9,7 +9,17 @@ import {
|
||||
createSelectParams,
|
||||
} from "../../utils/validators"
|
||||
|
||||
export const StoreGetProductParams = createSelectParams()
|
||||
export type StoreGetProductParamsType = z.infer<typeof StoreGetProductParams>
|
||||
|
||||
export const StoreGetProductParams = createSelectParams().merge(
|
||||
// These are used to populate the tax and pricing context
|
||||
z.object({
|
||||
region_id: z.string().optional(),
|
||||
country_code: z.string().optional(),
|
||||
province: z.string().optional(),
|
||||
cart_id: z.string().optional(),
|
||||
})
|
||||
)
|
||||
|
||||
export type StoreGetProductVariantsParamsType = z.infer<
|
||||
typeof StoreGetProductVariantsParams
|
||||
@@ -38,8 +48,12 @@ export const StoreGetProductsParams = createFindParams({
|
||||
}).merge(
|
||||
z
|
||||
.object({
|
||||
// These are used to populate the tax and pricing context
|
||||
region_id: z.string().optional(),
|
||||
currency_code: z.string().optional(),
|
||||
country_code: z.string().optional(),
|
||||
province: z.string().optional(),
|
||||
cart_id: z.string().optional(),
|
||||
|
||||
variants: z
|
||||
.object({
|
||||
status: ProductStatusEnum.array().optional(),
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextFunction } from "express"
|
||||
import { MedusaRequest } from "../../../../types/routing"
|
||||
|
||||
export function clearFiltersByKey(keys: string[]) {
|
||||
return async (req: MedusaRequest, _, next: NextFunction) => {
|
||||
keys.forEach((key) => {
|
||||
delete req.filterableFields[key]
|
||||
})
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./apply-default-filters"
|
||||
export * from "./clear-filters-by-key"
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./filter-by-valid-sales-channels"
|
||||
export * from "./normalize-data-for-context"
|
||||
export * from "./set-pricing-context"
|
||||
export * from "./set-tax-context"
|
||||
export * from "./variant-inventory-quantity"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { MedusaError } from "@medusajs/utils"
|
||||
import { NextFunction } from "express"
|
||||
import { AuthenticatedMedusaRequest } from "../../../../types/routing"
|
||||
import { refetchEntities, refetchEntity } from "../../refetch-entity"
|
||||
|
||||
export function normalizeDataForContext() {
|
||||
return async (req: AuthenticatedMedusaRequest, _, next: NextFunction) => {
|
||||
// If the product pricing is not requested, we don't need region information
|
||||
let withCalculatedPrice = req.remoteQueryConfig.fields.some((field) =>
|
||||
field.startsWith("variants.calculated_price")
|
||||
)
|
||||
|
||||
// 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.remoteQueryConfig.fields.push("variants.calculated_price.*")
|
||||
withCalculatedPrice = true
|
||||
}
|
||||
|
||||
if (!withCalculatedPrice) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// Region ID is required to calculate prices correctly.
|
||||
// Country code, and optionally province, are needed to calculate taxes.
|
||||
let regionId = req.filterableFields.region_id
|
||||
let countryCode = req.filterableFields.country_code
|
||||
let province = req.filterableFields.province
|
||||
|
||||
// If the cart is passed, get the information from it
|
||||
if (req.filterableFields.cart_id) {
|
||||
const cart = await refetchEntity(
|
||||
"cart",
|
||||
req.filterableFields.cart_id,
|
||||
req.scope,
|
||||
["region_id", "shipping_address.*"]
|
||||
)
|
||||
|
||||
if (cart?.region_id) {
|
||||
regionId = cart.region_id
|
||||
}
|
||||
|
||||
if (cart?.shipping_address) {
|
||||
countryCode = cart.shipping_address.country_code
|
||||
province = cart.shipping_address.province
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, try to get it from the store defaults if not available
|
||||
if (!regionId) {
|
||||
const stores = await refetchEntities("store", {}, req.scope, [
|
||||
"default_region_id",
|
||||
])
|
||||
regionId = stores[0]?.default_region_id
|
||||
}
|
||||
|
||||
if (!regionId) {
|
||||
try {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Missing required pricing context to calculate prices - region_id`
|
||||
)
|
||||
} catch (e) {
|
||||
return next(e)
|
||||
}
|
||||
}
|
||||
|
||||
req.filterableFields.region_id = regionId
|
||||
req.filterableFields.country_code = countryCode
|
||||
req.filterableFields.province = province
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MedusaPricingContext } from "@medusajs/types"
|
||||
import { isPresent, MedusaError } from "@medusajs/utils"
|
||||
import { MedusaError } from "@medusajs/utils"
|
||||
import { NextFunction } from "express"
|
||||
import { AuthenticatedMedusaRequest } from "../../../../types/routing"
|
||||
import { refetchEntities, refetchEntity } from "../../refetch-entity"
|
||||
@@ -9,106 +9,47 @@ export function setPricingContext() {
|
||||
const withCalculatedPrice = req.remoteQueryConfig.fields.some((field) =>
|
||||
field.startsWith("variants.calculated_price")
|
||||
)
|
||||
|
||||
// If the endpoint doesn't pass region_id and currency_code, we can exit early
|
||||
if (
|
||||
!withCalculatedPrice &&
|
||||
!req.filterableFields.region_id &&
|
||||
!req.filterableFields.currency_code
|
||||
) {
|
||||
if (!withCalculatedPrice) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// If the endpoint requested the field variants.calculated_price, we should throw
|
||||
// an error if region or currency is not passed
|
||||
if (
|
||||
withCalculatedPrice &&
|
||||
!req.filterableFields.region_id &&
|
||||
!req.filterableFields.currency_code
|
||||
) {
|
||||
// We validate the region ID in the previous middleware
|
||||
const region = await refetchEntity(
|
||||
"region",
|
||||
req.filterableFields.region_id!,
|
||||
req.scope,
|
||||
["id", "currency_code"]
|
||||
)
|
||||
|
||||
if (!region) {
|
||||
try {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Missing required pricing context to calculate prices - currency_code or region_id`
|
||||
`Region with id ${req.filterableFields.region_id} not found when populating the pricing context`
|
||||
)
|
||||
} catch (e) {
|
||||
return next(e)
|
||||
}
|
||||
}
|
||||
|
||||
const query = req.filterableFields || {}
|
||||
const pricingContext: MedusaPricingContext = {}
|
||||
const customerId = req.user?.customer_id
|
||||
|
||||
if (query.region_id) {
|
||||
const region = await refetchEntity("region", query.region_id, req.scope, [
|
||||
"id",
|
||||
"currency_code",
|
||||
])
|
||||
|
||||
if (region) {
|
||||
pricingContext.region_id = region.id
|
||||
}
|
||||
|
||||
if (region?.currency_code) {
|
||||
pricingContext.currency_code = region.currency_code
|
||||
}
|
||||
|
||||
delete req.filterableFields.region_id
|
||||
}
|
||||
|
||||
// If a currency code is explicitly passed, we should be using that instead of the
|
||||
// regions currency code
|
||||
if (query.currency_code) {
|
||||
const currency = await refetchEntity(
|
||||
"currency",
|
||||
{ code: query.currency_code },
|
||||
req.scope,
|
||||
["code"]
|
||||
)
|
||||
|
||||
if (currency) {
|
||||
pricingContext.currency_code = currency.code
|
||||
}
|
||||
|
||||
delete req.filterableFields.currency_code
|
||||
const pricingContext: MedusaPricingContext = {
|
||||
region_id: region.id,
|
||||
currency_code: region.currency_code,
|
||||
}
|
||||
|
||||
// Find all the customer groups the customer is a part of and set
|
||||
if (customerId) {
|
||||
if (req.user?.customer_id) {
|
||||
const customerGroups = await refetchEntities(
|
||||
"customer_group",
|
||||
{ customer_id: customerId },
|
||||
{ customer_id: req.user?.customer_id },
|
||||
req.scope,
|
||||
["id"]
|
||||
)
|
||||
|
||||
pricingContext.customer_group_id = customerGroups.map((cg) => cg.id)
|
||||
|
||||
delete req.filterableFields.customer_id
|
||||
}
|
||||
|
||||
// If a currency_code is not present in the context, we will not be able to calculate prices
|
||||
if (
|
||||
!isPresent(pricingContext) ||
|
||||
!isPresent(pricingContext.currency_code)
|
||||
) {
|
||||
try {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Valid pricing parameters (currency_code or region_id) are required to calculate prices`
|
||||
)
|
||||
} catch (e) {
|
||||
return next(e)
|
||||
}
|
||||
}
|
||||
|
||||
req.pricingContext = pricingContext
|
||||
|
||||
if (!withCalculatedPrice) {
|
||||
req.remoteQueryConfig.fields.push("variants.calculated_price.*")
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { TaxCalculationContext } from "@medusajs/types"
|
||||
import { NextFunction } from "express"
|
||||
import {
|
||||
AuthenticatedMedusaRequest,
|
||||
MedusaRequest,
|
||||
} from "../../../../types/routing"
|
||||
import { refetchEntity } from "../../refetch-entity"
|
||||
import { MedusaError } from "@medusajs/utils"
|
||||
import { RequestWithContext } from "../../../store/products/helpers"
|
||||
|
||||
export function setTaxContext() {
|
||||
return async (req: AuthenticatedMedusaRequest, _, next: NextFunction) => {
|
||||
const withCalculatedPrice = req.remoteQueryConfig.fields.some((field) =>
|
||||
field.startsWith("variants.calculated_price")
|
||||
)
|
||||
if (!withCalculatedPrice) {
|
||||
return next()
|
||||
}
|
||||
|
||||
try {
|
||||
const inclusivity = await getTaxInclusivityInfo(req)
|
||||
if (!inclusivity || !inclusivity.automaticTaxes) {
|
||||
return next()
|
||||
}
|
||||
|
||||
const taxLinesContext = await getTaxLinesContext(req)
|
||||
|
||||
// TODO: Allow passing a context typings param to AuthenticatedMedusaRequest
|
||||
;(req as unknown as RequestWithContext<any>).taxContext = {
|
||||
taxLineContext: taxLinesContext,
|
||||
taxInclusivityContext: inclusivity,
|
||||
}
|
||||
return next()
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getTaxInclusivityInfo = async (req: MedusaRequest) => {
|
||||
const region = await refetchEntity(
|
||||
"region",
|
||||
req.filterableFields.region_id as string,
|
||||
req.scope,
|
||||
["automatic_taxes"]
|
||||
)
|
||||
|
||||
if (!region) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Region with id ${req.filterableFields.region_id} not found when populating the tax context`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
automaticTaxes: region.automatic_taxes,
|
||||
}
|
||||
}
|
||||
|
||||
const getTaxLinesContext = async (req: MedusaRequest) => {
|
||||
if (!req.filterableFields.country_code) {
|
||||
return
|
||||
}
|
||||
|
||||
const taxContext = {
|
||||
address: {
|
||||
country_code: req.filterableFields.country_code as string,
|
||||
province_code: req.filterableFields.province as string,
|
||||
},
|
||||
} as TaxCalculationContext
|
||||
|
||||
return taxContext
|
||||
}
|
||||
Reference in new issue
Block a user