feat(tax): adds getItemTaxLines (#6440)

**What**
- Selects the correct tax line for an item given a calculation context.

**For later PR**
- Consider optimizations. Some thoughts:
  - Even with global sales the number of rates in the DB is not likely to grow beyond ~1000.
  - Can large orders with hundreds of items optimize somehow?
  - Does it make sense to write a custom SQL query to do this?
- Support combined rate.

**Test cases covered**
The selection of tax rates take the following priority:

1. specific product rules - province
2. specific product type rules - province
3. default province rules
4. specific product rules - country
5. specific product type rules - country
6. default country rules

There are test cases for each of them under the following data seed structure:

### **US** 
- **Default Rate**: 2%
- **Sub-Regions**
  - CA 
    - Default Rate: 5%
    - Overrides
      - Reduced rate (for 3 product ids): 3%
      - Reduced rate (for product type): 1%
  - NY 
    - Default rate: 6%
  - FL 
    - Default rate: 4%
- **Overrides**
  - None

### **Denmark** 
- **Default rate:** 25% 
- **Sub-Regions**
  - None
- **Overrides**
  -  None

### **Germany** 
- **Default Rate:** 19%
- **Sub-Regions**
  - None
- **Overrides:**
  - Reduced Rate (for product type) - 7%

### **Canada** 
- **Default rate**: 5%
- **Sub-Regions**
  - QC 
    - Default rate: 2%
    - Overrides:
      - Reduced rate (for same product type as country reduced rate): 1%
  - BC 
    - Default rate: 2%
- **Overrides**
  - Reduced rate (for product id) - 3%
  - Reduced rate (for product type) - 3.5%
This commit is contained in:
Sebastian Rindom
2024-02-22 16:28:55 +00:00
committed by GitHub
parent 58943e83fd
commit 598ee6f49c
13 changed files with 824 additions and 201 deletions
+6
View File
@@ -6,14 +6,17 @@ import {
import {
BeforeCreate,
Cascade,
Collection,
Entity,
ManyToOne,
OnInit,
OneToMany,
OptionalProps,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import TaxRegion from "./tax-region"
import TaxRateRule from "./tax-rate-rule"
type OptionalTaxRateProps = DAL.EntityDateColumns
@@ -63,6 +66,9 @@ export default class TaxRate {
})
tax_region: TaxRegion
@OneToMany(() => TaxRateRule, (rule) => rule.tax_rate)
rules = new Collection<TaxRateRule>(this)
@Property({ columnType: "jsonb", nullable: true })
metadata: Record<string, unknown> | null = null
+211 -1
View File
@@ -12,6 +12,7 @@ import {
InjectTransactionManager,
MedusaContext,
ModulesSdkUtils,
promiseAll,
} from "@medusajs/utils"
import { TaxRate, TaxRegion, TaxRateRule } from "@models"
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
@@ -128,7 +129,7 @@ export default class TaxModuleService<
sharedContext
)
const rates = regions.map((region, i) => {
const rates = regions.map((region: TaxRegionDTO, i: number) => {
return {
...defaultRates[i],
tax_region_id: region.id,
@@ -166,4 +167,213 @@ export default class TaxModuleService<
) {
return await this.taxRateRuleService_.create(data, sharedContext)
}
@InjectManager("baseRepository_")
async getTaxLines(
items: (TaxTypes.TaxableItemDTO | TaxTypes.TaxableShippingDTO)[],
calculationContext: TaxTypes.TaxCalculationContext,
@MedusaContext() sharedContext: Context = {}
): Promise<(TaxTypes.ItemTaxLineDTO | TaxTypes.ShippingTaxLineDTO)[]> {
const regions = await this.taxRegionService_.list(
{
$or: [
{
country_code: calculationContext.address.country_code,
province_code: null,
},
{
country_code: calculationContext.address.country_code,
province_code: calculationContext.address.province_code,
},
],
},
{},
sharedContext
)
const toReturn = await promiseAll(
items.map(async (item) => {
const regionIds = regions.map((r) => r.id)
const rateQuery = this.getTaxRateQueryForItem(item, regionIds)
const rates = await this.taxRateService_.list(
rateQuery,
{
relations: ["tax_region", "rules"],
},
sharedContext
)
return await this.getTaxRatesForItem(item, rates)
})
)
return toReturn.flat()
}
private async getTaxRatesForItem(
item: TaxTypes.TaxableItemDTO | TaxTypes.TaxableShippingDTO,
rates: TTaxRate[]
): Promise<(TaxTypes.ItemTaxLineDTO | TaxTypes.ShippingTaxLineDTO)[]> {
if (!rates.length) {
return []
}
const prioritizedRates = this.prioritizeRates(rates, item)
const rate = prioritizedRates[0]
const ratesToReturn = [this.buildRateForItem(rate, item)]
// 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(this.buildRateForItem(parentRate, item))
}
return ratesToReturn
}
private buildRateForItem(
rate: TTaxRate,
item: TaxTypes.TaxableItemDTO | TaxTypes.TaxableShippingDTO
): TaxTypes.ItemTaxLineDTO | TaxTypes.ShippingTaxLineDTO {
const isShipping = "shipping_option_id" in item
const toReturn = {
rate_id: rate.id,
rate: rate.rate,
code: rate.code,
name: rate.name,
}
if (isShipping) {
return {
...toReturn,
shipping_line_id: item.id,
}
}
return {
...toReturn,
line_item_id: item.id,
}
}
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: TTaxRate,
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: TTaxRate[],
item: TaxTypes.TaxableItemDTO | TaxTypes.TaxableShippingDTO
) {
const decoratedRates: (TTaxRate & {
priority_score: number
})[] = 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
})
return decoratedRates.sort(
(a, b) => (a as any).priority_score - (b as any).priority_score
)
}
}