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:
Stevche Radevski
2024-07-09 09:37:13 +00:00
committed by GitHub
parent db6969578f
commit 1c3ef13371
22 changed files with 824 additions and 121 deletions
@@ -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(),