508 lines
15 KiB
TypeScript
508 lines
15 KiB
TypeScript
import { AwilixContainer } from "awilix"
|
|
import { MedusaError } from "medusa-core-utils"
|
|
import { In } from "typeorm"
|
|
|
|
import { ICacheService, IEventBusService } from "@medusajs/types"
|
|
import {
|
|
ITaxService,
|
|
ItemTaxCalculationLine,
|
|
TaxCalculationContext,
|
|
TransactionBaseService,
|
|
} from "../interfaces"
|
|
import {
|
|
Cart,
|
|
LineItem,
|
|
LineItemTaxLine,
|
|
Region,
|
|
ShippingMethod,
|
|
ShippingMethodTaxLine,
|
|
TaxProvider,
|
|
} from "../models"
|
|
import { LineItemTaxLineRepository } from "../repositories/line-item-tax-line"
|
|
import { ShippingMethodTaxLineRepository } from "../repositories/shipping-method-tax-line"
|
|
import { TaxProviderRepository } from "../repositories/tax-provider"
|
|
import { isCart } from "../types/cart"
|
|
import { TaxLinesMaps, TaxServiceRate } from "../types/tax-service"
|
|
import TaxRateService from "./tax-rate"
|
|
|
|
type RegionDetails = {
|
|
id: string
|
|
tax_rate: number | null
|
|
}
|
|
|
|
/**
|
|
* Finds tax providers and assists in tax related operations.
|
|
*/
|
|
class TaxProviderService extends TransactionBaseService {
|
|
protected readonly container_: AwilixContainer
|
|
protected readonly cacheService_: ICacheService
|
|
protected readonly taxRateService_: TaxRateService
|
|
protected readonly taxLineRepo_: typeof LineItemTaxLineRepository
|
|
protected readonly smTaxLineRepo_: typeof ShippingMethodTaxLineRepository
|
|
protected readonly taxProviderRepo_: typeof TaxProviderRepository
|
|
protected readonly eventBus_: IEventBusService
|
|
|
|
constructor(container: AwilixContainer) {
|
|
super(container)
|
|
|
|
this.container_ = container
|
|
this.cacheService_ = container["cacheService"]
|
|
this.taxLineRepo_ = container["lineItemTaxLineRepository"]
|
|
this.smTaxLineRepo_ = container["shippingMethodTaxLineRepository"]
|
|
this.taxRateService_ = container["taxRateService"]
|
|
this.eventBus_ = container["eventBusService"]
|
|
this.taxProviderRepo_ = container["taxProviderRepository"]
|
|
}
|
|
|
|
async list(): Promise<TaxProvider[]> {
|
|
const tpRepo = this.activeManager_.withRepository(this.taxProviderRepo_)
|
|
return tpRepo.find({})
|
|
}
|
|
|
|
/**
|
|
* Retrieves the relevant tax provider for the given region.
|
|
* @param region - the region to get tax provider for.
|
|
* @return the region specific tax provider
|
|
*/
|
|
retrieveProvider(region: Region): ITaxService {
|
|
let provider: ITaxService
|
|
if (region.tax_provider_id) {
|
|
try {
|
|
provider = this.container_[`tp_${region.tax_provider_id}`]
|
|
} catch (e) {
|
|
// noop
|
|
}
|
|
} else {
|
|
provider = this.container_["systemTaxService"]
|
|
}
|
|
|
|
if (!provider!) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.NOT_FOUND,
|
|
`Could not find a tax provider with id: ${region.tax_provider_id}`
|
|
)
|
|
}
|
|
|
|
return provider
|
|
}
|
|
|
|
async clearLineItemsTaxLines(itemIds: string[]): Promise<void> {
|
|
return await this.atomicPhase_(async (transactionManager) => {
|
|
const taxLineRepo = transactionManager.withRepository(this.taxLineRepo_)
|
|
|
|
await taxLineRepo.delete({ item_id: In(itemIds) })
|
|
})
|
|
}
|
|
|
|
async clearTaxLines(cartId: string): Promise<void> {
|
|
return await this.atomicPhase_(async (transactionManager) => {
|
|
const taxLineRepo = transactionManager.withRepository(this.taxLineRepo_)
|
|
const shippingTaxRepo = transactionManager.withRepository(
|
|
this.smTaxLineRepo_
|
|
)
|
|
|
|
await Promise.all([
|
|
taxLineRepo.deleteForCart(cartId),
|
|
shippingTaxRepo.deleteForCart(cartId),
|
|
])
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Persists the tax lines relevant for an order to the database.
|
|
* @param cartOrLineItems - the cart or line items to create tax lines for
|
|
* @param calculationContext - the calculation context to get tax lines by
|
|
* @return the newly created tax lines
|
|
*/
|
|
async createTaxLines(
|
|
cartOrLineItems: Cart | LineItem[],
|
|
calculationContext: TaxCalculationContext
|
|
): Promise<(ShippingMethodTaxLine | LineItemTaxLine)[]> {
|
|
return await this.atomicPhase_(async (transactionManager) => {
|
|
let taxLines: (ShippingMethodTaxLine | LineItemTaxLine)[] = []
|
|
if (isCart(cartOrLineItems)) {
|
|
taxLines = await this.getTaxLines(
|
|
cartOrLineItems.items,
|
|
calculationContext
|
|
)
|
|
} else {
|
|
taxLines = await this.getTaxLines(cartOrLineItems, calculationContext)
|
|
}
|
|
|
|
const itemTaxLineRepo = transactionManager.withRepository(
|
|
this.taxLineRepo_
|
|
)
|
|
const shippingTaxLineRepo = transactionManager.withRepository(
|
|
this.smTaxLineRepo_
|
|
)
|
|
|
|
const { shipping, lineItems } = taxLines.reduce<{
|
|
shipping: ShippingMethodTaxLine[]
|
|
lineItems: LineItemTaxLine[]
|
|
}>(
|
|
(acc, tl) => {
|
|
if ("item_id" in tl) {
|
|
acc.lineItems.push(tl)
|
|
} else {
|
|
acc.shipping.push(tl)
|
|
}
|
|
|
|
return acc
|
|
},
|
|
{ shipping: [], lineItems: [] }
|
|
)
|
|
|
|
return (
|
|
await Promise.all([
|
|
itemTaxLineRepo.upsertLines(lineItems),
|
|
shippingTaxLineRepo.upsertLines(shipping),
|
|
])
|
|
).flat()
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Persists the tax lines relevant for a shipping method to the database. Used
|
|
* for return shipping methods.
|
|
* @param shippingMethod - the shipping method to create tax lines for
|
|
* @param calculationContext - the calculation context to get tax lines by
|
|
* @return the newly created tax lines
|
|
*/
|
|
async createShippingTaxLines(
|
|
shippingMethod: ShippingMethod,
|
|
calculationContext: TaxCalculationContext
|
|
): Promise<(ShippingMethodTaxLine | LineItemTaxLine)[]> {
|
|
return await this.atomicPhase_(async (transactionManager) => {
|
|
const taxLines = await this.getShippingTaxLines(
|
|
shippingMethod,
|
|
calculationContext
|
|
)
|
|
return await transactionManager.save(taxLines)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Gets the relevant tax lines for a shipping method. Note: this method
|
|
* doesn't persist the tax lines. Use createShippingTaxLines if you wish to
|
|
* persist the tax lines to the DB layer.
|
|
* @param shippingMethod - the shipping method to get tax lines for
|
|
* @param calculationContext - the calculation context to get tax lines by
|
|
* @return the computed tax lines
|
|
*/
|
|
async getShippingTaxLines(
|
|
shippingMethod: ShippingMethod,
|
|
calculationContext: TaxCalculationContext
|
|
): Promise<ShippingMethodTaxLine[]> {
|
|
const calculationLines = [
|
|
{
|
|
shipping_method: shippingMethod,
|
|
rates: await this.getRegionRatesForShipping(
|
|
shippingMethod.shipping_option_id,
|
|
calculationContext.region
|
|
),
|
|
},
|
|
]
|
|
|
|
const taxProvider = this.retrieveProvider(calculationContext.region)
|
|
const providerLines = await taxProvider.getTaxLines(
|
|
[],
|
|
calculationLines,
|
|
calculationContext
|
|
)
|
|
|
|
const smTaxLineRepo = this.activeManager_.withRepository(
|
|
this.smTaxLineRepo_
|
|
)
|
|
|
|
// .create only creates entities nothing is persisted in DB
|
|
return providerLines.map((pl) => {
|
|
if (!("shipping_method_id" in pl)) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.UNEXPECTED_STATE,
|
|
"Expected only shipping method tax lines"
|
|
)
|
|
}
|
|
|
|
return smTaxLineRepo.create({
|
|
shipping_method_id: pl.shipping_method_id,
|
|
rate: pl.rate,
|
|
name: pl.name,
|
|
code: pl.code,
|
|
metadata: pl.metadata,
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Gets the relevant tax lines for an order or cart. If an order is provided
|
|
* the order's tax lines will be returned. If a cart is provided the tax lines
|
|
* will be computed from the tax rules and potentially a 3rd party tax plugin.
|
|
* Note: this method doesn't persist the tax lines. Use createTaxLines if you
|
|
* wish to persist the tax lines to the DB layer.
|
|
* @param lineItems - the cart or order to get tax lines for
|
|
* @param calculationContext - the calculation context to get tax lines by
|
|
* @return the computed tax lines
|
|
*/
|
|
async getTaxLines(
|
|
lineItems: LineItem[],
|
|
calculationContext: TaxCalculationContext
|
|
): Promise<(ShippingMethodTaxLine | LineItemTaxLine)[]> {
|
|
const productIds = [
|
|
...new Set(
|
|
lineItems.map((item) => item?.variant?.product_id).filter((p) => p)
|
|
),
|
|
]
|
|
|
|
const productRatesMap = await this.getRegionRatesForProduct(
|
|
productIds,
|
|
calculationContext.region
|
|
)
|
|
|
|
const calculationLines = lineItems.map((item) => {
|
|
if (item.is_return) {
|
|
return null
|
|
}
|
|
|
|
if (item.variant?.product_id) {
|
|
return {
|
|
item: item,
|
|
rates: productRatesMap.get(item.variant?.product_id) ?? [],
|
|
}
|
|
}
|
|
|
|
/*
|
|
* If the line item is custom and therefore not associated with a
|
|
* product we assume no taxes - we should consider adding rate overrides
|
|
* to custom lines at some point
|
|
*/
|
|
return {
|
|
item: item,
|
|
rates: [],
|
|
}
|
|
})
|
|
|
|
const shippingCalculationLines = await Promise.all(
|
|
calculationContext.shipping_methods.map(async (sm) => {
|
|
return {
|
|
shipping_method: sm,
|
|
rates: await this.getRegionRatesForShipping(
|
|
sm.shipping_option_id,
|
|
calculationContext.region
|
|
),
|
|
}
|
|
})
|
|
)
|
|
|
|
const taxProvider = this.retrieveProvider(calculationContext.region)
|
|
const providerLines = await taxProvider.getTaxLines(
|
|
calculationLines.filter((v) => v !== null) as ItemTaxCalculationLine[],
|
|
shippingCalculationLines,
|
|
calculationContext
|
|
)
|
|
|
|
const liTaxLineRepo = this.activeManager_.withRepository(this.taxLineRepo_)
|
|
const smTaxLineRepo = this.activeManager_.withRepository(
|
|
this.smTaxLineRepo_
|
|
)
|
|
|
|
// .create only creates entities nothing is persisted in DB
|
|
return providerLines.map((pl) => {
|
|
if ("shipping_method_id" in pl) {
|
|
return smTaxLineRepo.create({
|
|
shipping_method_id: pl.shipping_method_id,
|
|
rate: pl.rate,
|
|
name: pl.name,
|
|
code: pl.code,
|
|
metadata: pl.metadata,
|
|
})
|
|
}
|
|
|
|
if (!("item_id" in pl)) {
|
|
throw new MedusaError(
|
|
MedusaError.Types.UNEXPECTED_STATE,
|
|
"Tax Provider returned invalid tax lines"
|
|
)
|
|
}
|
|
|
|
return liTaxLineRepo.create({
|
|
item_id: pl.item_id,
|
|
rate: pl.rate,
|
|
name: pl.name,
|
|
code: pl.code,
|
|
metadata: pl.metadata,
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Return a map of tax lines for line items and shipping methods
|
|
* @param items
|
|
* @param calculationContext
|
|
* @protected
|
|
*/
|
|
async getTaxLinesMap(
|
|
items: LineItem[],
|
|
calculationContext: TaxCalculationContext
|
|
): Promise<TaxLinesMaps> {
|
|
const lineItemsTaxLinesMap = {}
|
|
const shippingMethodsTaxLinesMap = {}
|
|
|
|
const taxLines = await this.getTaxLines(items, calculationContext)
|
|
|
|
taxLines.forEach((taxLine) => {
|
|
if ("item_id" in taxLine) {
|
|
const itemTaxLines = lineItemsTaxLinesMap[taxLine.item_id] ?? []
|
|
itemTaxLines.push(taxLine)
|
|
lineItemsTaxLinesMap[taxLine.item_id] = itemTaxLines
|
|
}
|
|
if ("shipping_method_id" in taxLine) {
|
|
const shippingMethodTaxLines =
|
|
shippingMethodsTaxLinesMap[taxLine.shipping_method_id] ?? []
|
|
shippingMethodTaxLines.push(taxLine)
|
|
shippingMethodsTaxLinesMap[taxLine.shipping_method_id] =
|
|
shippingMethodTaxLines
|
|
}
|
|
})
|
|
|
|
return {
|
|
lineItemsTaxLines: lineItemsTaxLinesMap,
|
|
shippingMethodsTaxLines: shippingMethodsTaxLinesMap,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the tax rates configured for a shipping option. The rates are cached
|
|
* between calls.
|
|
* @param optionId - the option id of the shipping method.
|
|
* @param regionDetails - the region to get configured rates for.
|
|
* @return the tax rates configured for the shipping option.
|
|
*/
|
|
async getRegionRatesForShipping(
|
|
optionId: string,
|
|
regionDetails: RegionDetails
|
|
): Promise<TaxServiceRate[]> {
|
|
const cacheKey = this.getCacheKey(optionId, regionDetails.id)
|
|
const cacheHit = await this.cacheService_.get<TaxServiceRate[]>(cacheKey)
|
|
if (cacheHit) {
|
|
return cacheHit
|
|
}
|
|
|
|
let toReturn: TaxServiceRate[] = []
|
|
const optionRates = await this.taxRateService_
|
|
.withTransaction(this.activeManager_)
|
|
.listByShippingOption(optionId)
|
|
|
|
if (optionRates.length > 0) {
|
|
toReturn = optionRates.map((pr) => {
|
|
return {
|
|
rate: pr.rate,
|
|
name: pr.name,
|
|
code: pr.code,
|
|
}
|
|
})
|
|
}
|
|
|
|
if (toReturn.length === 0) {
|
|
toReturn = [
|
|
{
|
|
rate: regionDetails.tax_rate,
|
|
name: "default",
|
|
code: "default",
|
|
},
|
|
]
|
|
}
|
|
|
|
await this.cacheService_.set(cacheKey, toReturn)
|
|
|
|
return toReturn
|
|
}
|
|
|
|
/**
|
|
* Gets the tax rates configured for a product. The rates are cached between
|
|
* calls.
|
|
* @param productIds
|
|
* @param region - the region to get configured rates for.
|
|
* @return the tax rates configured for the shipping option. A map by product id
|
|
*/
|
|
async getRegionRatesForProduct(
|
|
productIds: string | string[],
|
|
region: RegionDetails
|
|
): Promise<Map<string, TaxServiceRate[]>> {
|
|
productIds = Array.isArray(productIds) ? productIds : [productIds]
|
|
|
|
const nonCachedProductIds: string[] = []
|
|
|
|
const cacheKeysMap = new Map(
|
|
productIds.map((id) => [id, this.getCacheKey(id, region.id)])
|
|
)
|
|
|
|
const productRatesMapResult = new Map<string, TaxServiceRate[]>()
|
|
await Promise.all(
|
|
[...cacheKeysMap].map(async ([id, cacheKey]) => {
|
|
const cacheHit = await this.cacheService_.get<TaxServiceRate[]>(
|
|
cacheKey
|
|
)
|
|
|
|
if (!cacheHit) {
|
|
nonCachedProductIds.push(id)
|
|
return
|
|
}
|
|
|
|
productRatesMapResult.set(id, cacheHit)
|
|
})
|
|
)
|
|
|
|
// All products rates are cached so we can return early
|
|
if (!nonCachedProductIds.length) {
|
|
return productRatesMapResult
|
|
}
|
|
|
|
await Promise.all(
|
|
nonCachedProductIds.map(async (id) => {
|
|
const rates = await this.taxRateService_
|
|
.withTransaction(this.activeManager_)
|
|
.listByProduct(id, {
|
|
region_id: region.id,
|
|
})
|
|
|
|
const toReturn: TaxServiceRate[] = rates.length
|
|
? rates
|
|
: [
|
|
{
|
|
rate: region.tax_rate,
|
|
name: "default",
|
|
code: "default",
|
|
},
|
|
]
|
|
|
|
await this.cacheService_.set(cacheKeysMap.get(id)!, toReturn)
|
|
productRatesMapResult.set(id, toReturn)
|
|
})
|
|
)
|
|
|
|
return productRatesMapResult
|
|
}
|
|
|
|
/**
|
|
* The cache key to get cache hits by.
|
|
* @param id - the entity id to cache
|
|
* @param regionId - the region id to cache
|
|
* @return the cache key to use for the id set
|
|
*/
|
|
private getCacheKey(id: string, regionId: string): string {
|
|
return `txrtcache:${id}:${regionId}`
|
|
}
|
|
|
|
async registerInstalledProviders(providers: string[]): Promise<void> {
|
|
const model = this.activeManager_.withRepository(this.taxProviderRepo_)
|
|
await model.update({}, { is_installed: false })
|
|
|
|
for (const p of providers) {
|
|
const n = model.create({ id: p, is_installed: true })
|
|
await model.save(n)
|
|
}
|
|
}
|
|
}
|
|
|
|
export default TaxProviderService
|