Files
medusa-store/packages/modules/tax/src/services/tax-module-service.ts
Oli Juhl 505768dd9f fix: Product type tax overrides (#9951)
* fix: Make product type tax override work

* fix: Make product type tax override work
2024-11-06 20:33:40 +01:00

739 lines
21 KiB
TypeScript

import {
Context,
DAL,
InternalModuleDeclaration,
ITaxModuleService,
ITaxProvider,
ModulesSdkTypes,
TaxRegionDTO,
TaxTypes,
} from "@medusajs/framework/types"
import {
InjectManager,
InjectTransactionManager,
isDefined,
isString,
MedusaContext,
MedusaError,
ModulesSdkUtils,
promiseAll,
} from "@medusajs/framework/utils"
import { TaxProvider, TaxRate, TaxRateRule, TaxRegion } from "@models"
type InjectedDependencies = {
baseRepository: DAL.RepositoryService
taxRateService: ModulesSdkTypes.IMedusaInternalService<any>
taxRegionService: ModulesSdkTypes.IMedusaInternalService<any>
taxRateRuleService: ModulesSdkTypes.IMedusaInternalService<any>
taxProviderService: ModulesSdkTypes.IMedusaInternalService<any>
[key: `tp_${string}`]: ITaxProvider
}
const generateForModels = { TaxRate, TaxRegion, TaxRateRule, TaxProvider }
type ItemWithRates = {
rates: TaxRate[]
item: TaxTypes.TaxableItemDTO | TaxTypes.TaxableShippingDTO
}
export default class TaxModuleService
extends ModulesSdkUtils.MedusaService<{
TaxRate: { dto: TaxTypes.TaxRateDTO }
TaxRegion: { dto: TaxTypes.TaxRegionDTO }
TaxRateRule: { dto: TaxTypes.TaxRateRuleDTO }
TaxProvider: { dto: TaxTypes.TaxProviderDTO }
}>(generateForModels)
implements ITaxModuleService
{
protected readonly container_: InjectedDependencies
protected baseRepository_: DAL.RepositoryService
protected taxRateService_: ModulesSdkTypes.IMedusaInternalService<TaxRate>
protected taxRegionService_: ModulesSdkTypes.IMedusaInternalService<TaxRegion>
protected taxRateRuleService_: ModulesSdkTypes.IMedusaInternalService<TaxRateRule>
protected taxProviderService_: ModulesSdkTypes.IMedusaInternalService<TaxProvider>
constructor(
{
baseRepository,
taxRateService,
taxRegionService,
taxRateRuleService,
taxProviderService,
}: InjectedDependencies,
protected readonly moduleDeclaration: InternalModuleDeclaration
) {
// @ts-ignore
super(...arguments)
this.container_ = arguments[0]
this.baseRepository_ = baseRepository
this.taxRateService_ = taxRateService
this.taxRegionService_ = taxRegionService
this.taxRateRuleService_ = taxRateRuleService
this.taxProviderService_ = taxProviderService
}
// @ts-expect-error
async createTaxRates(
data: TaxTypes.CreateTaxRateDTO[],
sharedContext?: Context
): Promise<TaxTypes.TaxRateDTO[]>
async createTaxRates(
data: TaxTypes.CreateTaxRateDTO,
sharedContext?: Context
): Promise<TaxTypes.TaxRateDTO>
@InjectManager()
async createTaxRates(
data: TaxTypes.CreateTaxRateDTO[] | TaxTypes.CreateTaxRateDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<TaxTypes.TaxRateDTO[] | TaxTypes.TaxRateDTO> {
const input = Array.isArray(data) ? data : [data]
const rates = await this.createTaxRates_(input, sharedContext)
return Array.isArray(data) ? rates : rates[0]
}
@InjectTransactionManager()
protected async createTaxRates_(
data: TaxTypes.CreateTaxRateDTO[],
@MedusaContext() sharedContext: Context = {}
) {
const [rules, rateData] = data.reduce(
(acc, region) => {
const { rules, ...rest } = region
acc[0].push(rules)
acc[1].push(rest)
return acc
},
[[], []] as [
(Omit<TaxTypes.CreateTaxRateRuleDTO, "tax_rate_id">[] | undefined)[],
Partial<TaxTypes.CreateTaxRegionDTO>[]
]
)
const rates = await this.taxRateService_.create(rateData, sharedContext)
const rulesToCreate = rates
.reduce((acc, rate, i) => {
const rateRules = rules[i]
if (isDefined(rateRules)) {
acc.push(
rateRules.map((r) => {
return {
...r,
created_by: rate.created_by,
tax_rate_id: rate.id,
}
})
)
}
return acc
}, [] as TaxTypes.CreateTaxRateRuleDTO[][])
.flat()
if (rulesToCreate.length > 0) {
await this.taxRateRuleService_.create(rulesToCreate, sharedContext)
}
return await this.baseRepository_.serialize<TaxTypes.TaxRateDTO[]>(rates, {
populate: true,
})
}
// @ts-expect-error
async updateTaxRates(
id: string,
data: TaxTypes.UpdateTaxRateDTO,
sharedContext?: Context
): Promise<TaxTypes.TaxRateDTO>
async updateTaxRates(
ids: string[],
data: TaxTypes.UpdateTaxRateDTO,
sharedContext?: Context
): Promise<TaxTypes.TaxRateDTO[]>
async updateTaxRates(
selector: TaxTypes.FilterableTaxRateProps,
data: TaxTypes.UpdateTaxRateDTO,
sharedContext?: Context
): Promise<TaxTypes.TaxRateDTO[]>
@InjectManager()
async updateTaxRates(
selector: string | string[] | TaxTypes.FilterableTaxRateProps,
data: TaxTypes.UpdateTaxRateDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<TaxTypes.TaxRateDTO | TaxTypes.TaxRateDTO[]> {
const rates = await this.updateTaxRates_(selector, data, sharedContext)
const serialized = await this.baseRepository_.serialize<
TaxTypes.TaxRateDTO[]
>(rates, { populate: true })
return isString(selector) ? serialized[0] : serialized
}
@InjectTransactionManager()
protected async updateTaxRates_(
idOrSelector: string | string[] | TaxTypes.FilterableTaxRateProps,
data: TaxTypes.UpdateTaxRateDTO,
@MedusaContext() sharedContext: Context = {}
) {
const selector =
Array.isArray(idOrSelector) || isString(idOrSelector)
? { id: idOrSelector }
: idOrSelector
if (data.rules) {
await this.setTaxRateRulesForTaxRates(
idOrSelector,
data.rules,
data.updated_by,
sharedContext
)
delete data.rules
}
return await this.taxRateService_.update({ selector, data }, sharedContext)
}
private async setTaxRateRulesForTaxRates(
idOrSelector: string | string[] | TaxTypes.FilterableTaxRateProps,
rules: Omit<TaxTypes.CreateTaxRateRuleDTO, "tax_rate_id">[],
createdBy?: string,
sharedContext: Context = {}
) {
const selector =
Array.isArray(idOrSelector) || isString(idOrSelector)
? { id: idOrSelector }
: idOrSelector
await this.taxRateRuleService_.softDelete(
{ tax_rate: selector },
sharedContext
)
// TODO: this is a temporary solution seems like mikro-orm doesn't persist
// the soft delete which results in the creation below breaking the unique
// constraint
await this.taxRateRuleService_.list(
{ tax_rate: selector },
{ select: ["id"] },
sharedContext
)
if (rules.length === 0) {
return
}
const rateIds = await this.getTaxRateIdsFromSelector(idOrSelector)
const toCreate = rateIds
.map((id) => {
return rules.map((r) => {
return {
...r,
created_by: createdBy,
tax_rate_id: id,
}
})
})
.flat()
return await this.createTaxRateRules(toCreate, sharedContext)
}
private async getTaxRateIdsFromSelector(
idOrSelector: string | string[] | TaxTypes.FilterableTaxRateProps,
sharedContext: Context = {}
) {
if (Array.isArray(idOrSelector)) {
return idOrSelector
}
if (isString(idOrSelector)) {
return [idOrSelector]
}
const rates = await this.taxRateService_.list(
idOrSelector,
{ select: ["id"] },
sharedContext
)
return rates.map((r) => r.id)
}
async upsertTaxRates(
data: TaxTypes.UpsertTaxRateDTO[],
sharedContext?: Context
): Promise<TaxTypes.TaxRateDTO[]>
async upsertTaxRates(
data: TaxTypes.UpsertTaxRateDTO,
sharedContext?: Context
): Promise<TaxTypes.TaxRateDTO>
@InjectTransactionManager()
async upsertTaxRates(
data: TaxTypes.UpsertTaxRateDTO | TaxTypes.UpsertTaxRateDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TaxTypes.TaxRateDTO | TaxTypes.TaxRateDTO[]> {
const result = await this.taxRateService_.upsert(data, sharedContext)
const serialized = await this.baseRepository_.serialize<
TaxTypes.TaxRateDTO[]
>(result, { populate: true })
return Array.isArray(data) ? serialized : serialized[0]
}
// @ts-ignore
createTaxRegions(
data: TaxTypes.CreateTaxRegionDTO,
sharedContext?: Context
): Promise<TaxRegionDTO>
createTaxRegions(
data: TaxTypes.CreateTaxRegionDTO[],
sharedContext?: Context
): Promise<TaxRegionDTO[]>
@InjectManager()
async createTaxRegions(
data: TaxTypes.CreateTaxRegionDTO | TaxTypes.CreateTaxRegionDTO[],
@MedusaContext() sharedContext: Context = {}
) {
const input = Array.isArray(data) ? data : [data]
const result = await this.createTaxRegions_(input, sharedContext)
return Array.isArray(data) ? result : result[0]
}
async createTaxRegions_(
data: TaxTypes.CreateTaxRegionDTO[],
sharedContext: Context = {}
) {
const { defaultRates, regionData } =
this.prepareTaxRegionInputForCreate(data)
await this.verifyProvinceToCountryMatch(regionData, sharedContext)
const regions = await this.taxRegionService_.create(
regionData,
sharedContext
)
const rates = regions
.map((region, i) => {
if (!defaultRates[i]) {
return false
}
return {
...defaultRates[i],
tax_region_id: region.id,
}
})
.filter(Boolean) as TaxTypes.CreateTaxRateDTO[]
if (rates.length !== 0) {
await this.createTaxRates(rates, sharedContext)
}
return await this.baseRepository_.serialize<TaxTypes.TaxRegionDTO[]>(
regions,
{ populate: true }
)
}
// @ts-ignore
createTaxRateRules(
data: TaxTypes.CreateTaxRateRuleDTO,
sharedContext?: Context
): Promise<TaxTypes.TaxRateRuleDTO>
createTaxRateRules(
data: TaxTypes.CreateTaxRateRuleDTO[],
sharedContext?: Context
): Promise<TaxTypes.TaxRateRuleDTO[]>
@InjectManager()
async createTaxRateRules(
data: TaxTypes.CreateTaxRateRuleDTO | TaxTypes.CreateTaxRateRuleDTO[],
@MedusaContext() sharedContext: Context = {}
) {
const input = Array.isArray(data) ? data : [data]
const result = await this.createTaxRateRules_(input, sharedContext)
return Array.isArray(data) ? result : result[0]
}
@InjectTransactionManager()
async createTaxRateRules_(
data: TaxTypes.CreateTaxRateRuleDTO[],
@MedusaContext() sharedContext: Context = {}
) {
const rules = await this.taxRateRuleService_.create(data, sharedContext)
return await this.baseRepository_.serialize<TaxTypes.TaxRateRuleDTO[]>(
rules,
{
populate: true,
}
)
}
@InjectManager()
async getTaxLines(
items: (TaxTypes.TaxableItemDTO | TaxTypes.TaxableShippingDTO)[],
calculationContext: TaxTypes.TaxCalculationContext,
@MedusaContext() sharedContext: Context = {}
): Promise<(TaxTypes.ItemTaxLineDTO | TaxTypes.ShippingTaxLineDTO)[]> {
const normalizedContext =
this.normalizeTaxCalculationContext(calculationContext)
const regions = await this.taxRegionService_.list(
{
$or: [
{
country_code: normalizedContext.address.country_code,
province_code: null,
},
{
country_code: normalizedContext.address.country_code,
province_code: normalizedContext.address.province_code,
},
],
},
{},
sharedContext
)
const parentRegion = regions.find((r) => r.province_code === null)
if (!parentRegion) {
return []
}
const toReturn = await promiseAll(
items.map(async (item) => {
const regionIds = regions.map((r) => r.id)
const rateQuery = this.getTaxRateQueryForItem(item, regionIds)
const candidateRates = await this.taxRateService_.list(
rateQuery,
{
relations: ["tax_region", "rules"],
},
sharedContext
)
const applicableRates = await this.getTaxRatesForItem(
item,
candidateRates
)
return {
rates: applicableRates,
item,
}
})
)
const taxLines = await this.getTaxLinesFromProvider(
parentRegion.provider_id,
toReturn,
calculationContext
)
return taxLines
}
private async getTaxLinesFromProvider(
rawProviderId: string | null,
items: ItemWithRates[],
calculationContext: TaxTypes.TaxCalculationContext
) {
const providerId = rawProviderId || "system"
let provider: ITaxProvider
try {
provider = this.container_[`tp_${providerId}`] as ITaxProvider
} catch (err) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Failed to resolve Tax Provider with id: ${providerId}. Make sure it's installed and configured in the Tax Module's options.`
)
}
const [itemLines, shippingLines] = items.reduce(
(acc, line) => {
if ("shipping_option_id" in line.item) {
acc[1].push({
shipping_line: line.item,
rates: line.rates,
})
} else {
acc[0].push({
line_item: line.item,
rates: line.rates,
})
}
return acc
},
[[], []] as [
TaxTypes.ItemTaxCalculationLine[],
TaxTypes.ShippingTaxCalculationLine[]
]
)
const itemTaxLines = await provider.getTaxLines(
itemLines,
shippingLines,
calculationContext
)
return itemTaxLines
}
private normalizeTaxCalculationContext(
context: TaxTypes.TaxCalculationContext
): TaxTypes.TaxCalculationContext {
return {
...context,
address: {
...context.address,
country_code: this.normalizeRegionCodes(context.address.country_code),
province_code: context.address.province_code
? this.normalizeRegionCodes(context.address.province_code)
: null,
},
}
}
private prepareTaxRegionInputForCreate(
data: TaxTypes.CreateTaxRegionDTO | TaxTypes.CreateTaxRegionDTO[]
) {
const regionsWithDefaultRate = Array.isArray(data) ? data : [data]
const defaultRates: (Omit<
TaxTypes.CreateTaxRateDTO,
"tax_region_id"
> | null)[] = []
const regionData: TaxTypes.CreateTaxRegionDTO[] = []
for (const region of regionsWithDefaultRate) {
const { default_tax_rate, ...rest } = region
if (!default_tax_rate) {
defaultRates.push(null)
} else {
defaultRates.push({
...default_tax_rate,
is_default: true,
created_by: region.created_by,
})
}
regionData.push({
...rest,
province_code: rest.province_code
? this.normalizeRegionCodes(rest.province_code)
: null,
country_code: this.normalizeRegionCodes(rest.country_code),
})
}
return { defaultRates, regionData }
}
private async verifyProvinceToCountryMatch(
regionsToVerify: TaxTypes.CreateTaxRegionDTO[],
sharedContext: Context = {}
) {
const parentIds = regionsToVerify.map((i) => i.parent_id).filter(isDefined)
if (parentIds.length > 0) {
const parentRegions = await this.taxRegionService_.list(
{ id: { $in: parentIds } },
{ select: ["id", "country_code"] },
sharedContext
)
for (const region of regionsToVerify) {
if (isDefined(region.parent_id)) {
const parentRegion = parentRegions.find(
(r) => r.id === region.parent_id
)
if (!isDefined(parentRegion)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Province region must belong to a parent region. You are trying to create a province region with (country: ${region.country_code}, province: ${region.province_code}) but parent does not exist`
)
}
if (parentRegion.country_code !== region.country_code) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Province region must belong to a parent region with the same country code. You are trying to create a province region with (country: ${region.country_code}, province: ${region.province_code}) but parent expects (country: ${parentRegion.country_code})`
)
}
}
}
}
}
private async getTaxRatesForItem(
item: TaxTypes.TaxableItemDTO | TaxTypes.TaxableShippingDTO,
rates: TaxRate[]
): Promise<TaxRate[]> {
if (!rates.length) {
return []
}
const prioritizedRates = this.prioritizeRates(rates, item)
const rate = prioritizedRates[0]
const ratesToReturn = [rate]
// If the rate can be combined we need to find the rate's
// parent region and add that rate too. If not we can return now.
if (!(rate.is_combinable && rate.tax_region.parent_id)) {
return ratesToReturn
}
// First parent region rate in prioritized rates
// will be the most granular rate.
const parentRate = prioritizedRates.find(
(r) => r.tax_region.id === rate.tax_region.parent_id
)
if (parentRate) {
ratesToReturn.push(parentRate)
}
return ratesToReturn
}
private getTaxRateQueryForItem(
item: TaxTypes.TaxableItemDTO | TaxTypes.TaxableShippingDTO,
regionIds: string[]
) {
const isShipping = "shipping_option_id" in item
let ruleQuery = isShipping
? [
{
reference: "shipping_option",
reference_id: item.shipping_option_id,
},
]
: [
{
reference: "product",
reference_id: item.product_id,
},
{
reference: "product_type",
reference_id: item.product_type_id,
},
]
return {
$and: [
{ tax_region_id: regionIds },
{ $or: [{ is_default: true }, { rules: { $or: ruleQuery } }] },
],
}
}
private checkRuleMatches(
rate: TaxRate,
item: TaxTypes.TaxableItemDTO | TaxTypes.TaxableShippingDTO
) {
if (rate.rules.length === 0) {
return {
isProductMatch: false,
isProductTypeMatch: false,
isShippingMatch: false,
}
}
let isProductMatch = false
const isShipping = "shipping_option_id" in item
const matchingRules = rate.rules.filter((rule) => {
if (isShipping) {
return (
rule.reference === "shipping" &&
rule.reference_id === item.shipping_option_id
)
}
return (
(rule.reference === "product" &&
rule.reference_id === item.product_id) ||
(rule.reference === "product_type" &&
rule.reference_id === item.product_type_id)
)
})
if (matchingRules.some((rule) => rule.reference === "product")) {
isProductMatch = true
}
return {
isProductMatch,
isProductTypeMatch: matchingRules.length > 0,
isShippingMatch: isShipping && matchingRules.length > 0,
}
}
private prioritizeRates(
rates: TaxRate[],
item: TaxTypes.TaxableItemDTO | TaxTypes.TaxableShippingDTO
) {
const decoratedRates = rates.map((rate) => {
const { isProductMatch, isProductTypeMatch, isShippingMatch } =
this.checkRuleMatches(rate, item)
const isProvince = rate.tax_region.province_code !== null
const isDefault = rate.is_default
const decoratedRate = {
...rate,
priority_score: 7,
}
if ((isShippingMatch || isProductMatch) && isProvince) {
decoratedRate.priority_score = 1
} else if (isProductTypeMatch && isProvince) {
decoratedRate.priority_score = 2
} else if (isDefault && isProvince) {
decoratedRate.priority_score = 3
} else if ((isShippingMatch || isProductMatch) && !isProvince) {
decoratedRate.priority_score = 4
} else if (isProductTypeMatch && !isProvince) {
decoratedRate.priority_score = 5
} else if (isDefault && !isProvince) {
decoratedRate.priority_score = 6
}
return decoratedRate
}) as (TaxRate & {
priority_score: number
})[]
return decoratedRates.sort(
(a, b) => (a as any).priority_score - (b as any).priority_score
)
}
private normalizeRegionCodes(code: string) {
return code.toLowerCase()
}
// @InjectTransactionManager()
// async createProvidersOnLoad(@MedusaContext() sharedContext: Context = {}) {
// const providersToLoad = this.container_["tax_providers"] as ITaxProvider[]
// const ids = providersToLoad.map((p) => p.getIdentifier())
// const existing = await this.taxProviderService_.update(
// { selector: { id: { $in: ids } }, data: { is_enabled: true } },
// sharedContext
// )
// const existingIds = existing.map((p) => p.id)
// const diff = arrayDifference(ids, existingIds)
// await this.taxProviderService_.create(
// diff.map((id) => ({ id, is_enabled: true }))
// )
// await this.taxProviderService_.update({
// selector: { id: { $nin: ids } },
// data: { is_enabled: false },
// })
// }
}