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:
543
packages/tax/integration-tests/__tests__/index.spec.ts
Normal file
543
packages/tax/integration-tests/__tests__/index.spec.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
import { SuiteOptions, moduleIntegrationTestRunner } from "medusa-test-utils"
|
||||
import { ITaxModuleService } from "@medusajs/types"
|
||||
import { Modules } from "@medusajs/modules-sdk"
|
||||
|
||||
jest.setTimeout(30000)
|
||||
|
||||
moduleIntegrationTestRunner({
|
||||
moduleName: Modules.TAX,
|
||||
testSuite: ({ service }: SuiteOptions<ITaxModuleService>) => {
|
||||
describe("TaxModuleService", function () {
|
||||
it("should create a tax region", async () => {
|
||||
const [region] = await service.createTaxRegions([
|
||||
{
|
||||
country_code: "US",
|
||||
default_tax_rate: {
|
||||
name: "Test Rate",
|
||||
rate: 0.2,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const [provinceRegion] = await service.createTaxRegions([
|
||||
{
|
||||
country_code: "US",
|
||||
province_code: "CA",
|
||||
parent_id: region.id,
|
||||
default_tax_rate: {
|
||||
name: "CA Rate",
|
||||
rate: 8.25,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const listedRegions = await service.listTaxRegions()
|
||||
expect(listedRegions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: region.id,
|
||||
country_code: "US",
|
||||
province_code: null,
|
||||
parent_id: null,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: provinceRegion.id,
|
||||
country_code: "US",
|
||||
province_code: "CA",
|
||||
parent_id: region.id,
|
||||
}),
|
||||
])
|
||||
)
|
||||
|
||||
const rates = await service.list()
|
||||
expect(rates).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
tax_region_id: region.id,
|
||||
rate: 0.2,
|
||||
name: "Test Rate",
|
||||
is_default: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tax_region_id: provinceRegion.id,
|
||||
rate: 8.25,
|
||||
name: "CA Rate",
|
||||
is_default: true,
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("should create a tax rate rule", async () => {
|
||||
const [region] = await service.createTaxRegions([
|
||||
{
|
||||
country_code: "US",
|
||||
default_tax_rate: {
|
||||
name: "Test Rate",
|
||||
rate: 0.2,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const rate = await service.create({
|
||||
tax_region_id: region.id,
|
||||
name: "Shipping Rate",
|
||||
rate: 8.23,
|
||||
})
|
||||
|
||||
await service.createTaxRateRules([
|
||||
{
|
||||
tax_rate_id: rate.id,
|
||||
reference: "product",
|
||||
reference_id: "prod_1234",
|
||||
},
|
||||
])
|
||||
|
||||
const listedRules = await service.listTaxRateRules(
|
||||
{},
|
||||
{
|
||||
relations: ["tax_rate"],
|
||||
}
|
||||
)
|
||||
expect(listedRules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
reference: "product",
|
||||
reference_id: "prod_1234",
|
||||
tax_rate: expect.objectContaining({
|
||||
tax_region_id: region.id,
|
||||
name: "Shipping Rate",
|
||||
rate: 8.23,
|
||||
}),
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("applies specific product rules at the province level", async () => {
|
||||
await setupTaxStructure(service)
|
||||
const item = {
|
||||
id: "item_test",
|
||||
product_id: "product_id_1", // Matching the specific product rate for CA province
|
||||
quantity: 1,
|
||||
}
|
||||
const calculationContext = {
|
||||
address: {
|
||||
country_code: "US",
|
||||
province_code: "CA",
|
||||
},
|
||||
}
|
||||
|
||||
const taxLines = await service.getTaxLines([item], calculationContext)
|
||||
|
||||
expect(taxLines).toEqual([
|
||||
expect.objectContaining({
|
||||
rate_id: expect.any(String),
|
||||
rate: 3, // Expecting the reduced rate for specific products in CA
|
||||
code: "CAREDUCE_PROD",
|
||||
name: "CA Reduced Rate for Products",
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it("applies specific product type rules at the province level", async () => {
|
||||
await setupTaxStructure(service)
|
||||
const item = {
|
||||
id: "item_test",
|
||||
product_id: "product_id_unknown", // This product does not have a specific rule
|
||||
product_type_id: "product_type_id_1", // Matching the specific product type rate for CA province
|
||||
quantity: 1,
|
||||
}
|
||||
const calculationContext = {
|
||||
address: {
|
||||
country_code: "US",
|
||||
province_code: "CA",
|
||||
},
|
||||
}
|
||||
|
||||
const taxLines = await service.getTaxLines([item], calculationContext)
|
||||
|
||||
expect(taxLines).toEqual([
|
||||
expect.objectContaining({
|
||||
rate_id: expect.any(String),
|
||||
rate: 1, // Expecting the reduced rate for specific product types in CA
|
||||
code: "CAREDUCE_TYPE",
|
||||
name: "CA Reduced Rate for Product Type",
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it("applies specific product type rules at the province level", async () => {
|
||||
await setupTaxStructure(service)
|
||||
const item = {
|
||||
id: "item_test",
|
||||
product_id: "product_id_unknown", // This product does not have a specific rule
|
||||
product_type_id: "product_type_id_1", // Matching the specific product type rate for CA province
|
||||
quantity: 1,
|
||||
}
|
||||
const calculationContext = {
|
||||
address: {
|
||||
country_code: "US",
|
||||
province_code: "CA",
|
||||
},
|
||||
}
|
||||
|
||||
const taxLines = await service.getTaxLines([item], calculationContext)
|
||||
|
||||
expect(taxLines).toEqual([
|
||||
expect.objectContaining({
|
||||
rate_id: expect.any(String),
|
||||
rate: 1, // Expecting the reduced rate for specific product types in CA
|
||||
code: "CAREDUCE_TYPE",
|
||||
name: "CA Reduced Rate for Product Type",
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it("applies default province rules when no specific product or product type rule matches", async () => {
|
||||
await setupTaxStructure(service)
|
||||
const item = {
|
||||
id: "item_test",
|
||||
product_id: "product_id_unknown",
|
||||
quantity: 1,
|
||||
}
|
||||
const calculationContext = {
|
||||
address: {
|
||||
country_code: "US",
|
||||
province_code: "NY", // Testing with NY to apply the default provincial rate
|
||||
},
|
||||
}
|
||||
|
||||
const taxLines = await service.getTaxLines([item], calculationContext)
|
||||
|
||||
expect(taxLines).toEqual([
|
||||
expect.objectContaining({
|
||||
rate_id: expect.any(String),
|
||||
rate: 6, // Expecting the default rate for NY province
|
||||
code: "NYDEFAULT",
|
||||
name: "NY Default Rate",
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it("applies specific product rules at the country level when no province rate applies", async () => {
|
||||
await setupTaxStructure(service)
|
||||
const item = {
|
||||
id: "item_test",
|
||||
product_id: "product_id_4", // Assuming this ID now has a specific rule at the country level for Canada
|
||||
quantity: 1,
|
||||
}
|
||||
const calculationContext = {
|
||||
address: {
|
||||
country_code: "CA",
|
||||
province_code: "ON", // This province does not have a specific rule
|
||||
},
|
||||
}
|
||||
|
||||
const taxLines = await service.getTaxLines([item], calculationContext)
|
||||
|
||||
expect(taxLines).toEqual([
|
||||
expect.objectContaining({
|
||||
rate_id: expect.any(String),
|
||||
rate: 3, // Expecting the reduced rate for specific products in Canada
|
||||
code: "CAREDUCE_PROD_CA",
|
||||
name: "Canada Reduced Rate for Product",
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it("applies default country rules when no specific product or product type rule matches", async () => {
|
||||
await setupTaxStructure(service)
|
||||
const item = {
|
||||
id: "item_test",
|
||||
product_id: "product_id_unknown",
|
||||
quantity: 1,
|
||||
}
|
||||
const calculationContext = {
|
||||
address: {
|
||||
country_code: "DE", // Testing with Germany to apply the default country rate
|
||||
},
|
||||
}
|
||||
|
||||
const taxLines = await service.getTaxLines([item], calculationContext)
|
||||
|
||||
expect(taxLines).toEqual([
|
||||
expect.objectContaining({
|
||||
rate_id: expect.any(String),
|
||||
rate: 19,
|
||||
code: "DE19",
|
||||
name: "Germany Default Rate",
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it("prioritizes specific product rules over product type rules", async () => {
|
||||
await setupTaxStructure(service)
|
||||
|
||||
const item = {
|
||||
id: "item_test",
|
||||
product_id: "product_id_1", // This product has a specific rule for product type and product
|
||||
product_type_id: "product_type_id_1", // This product type has a specific rule for product type
|
||||
quantity: 1,
|
||||
}
|
||||
const calculationContext = {
|
||||
address: {
|
||||
country_code: "US",
|
||||
province_code: "CA",
|
||||
},
|
||||
}
|
||||
|
||||
const taxLines = await service.getTaxLines([item], calculationContext)
|
||||
|
||||
expect(taxLines).toEqual([
|
||||
expect.objectContaining({
|
||||
rate_id: expect.any(String),
|
||||
rate: 3, // Expecting the reduced rate for specific products in CA
|
||||
code: "CAREDUCE_PROD",
|
||||
name: "CA Reduced Rate for Products",
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const setupTaxStructure = async (service) => {
|
||||
// Setup for this specific test
|
||||
//
|
||||
// Using the following structure to setup tests.
|
||||
// US - default 2%
|
||||
// - Region: CA - default 5%
|
||||
// - Override: Reduced rate (for 3 product ids): 3%
|
||||
// - Override: Reduced rate (for product type): 1%
|
||||
// - Region: NY - default: 6%
|
||||
// - Region: FL - default: 4%
|
||||
//
|
||||
// Denmark - default 25%
|
||||
//
|
||||
// Germany - default 19%
|
||||
// - Override: Reduced Rate (for product type) - 7%
|
||||
//
|
||||
// Canada - default 5%
|
||||
// - Override: Reduced rate (for product id) - 3%
|
||||
// - Override: Reduced rate (for product type) - 3.5%
|
||||
// - Region: QC - default 2%
|
||||
// - Override: Reduced rate (for same product type as country reduced rate): 1%
|
||||
// - Region: BC - default 2%
|
||||
//
|
||||
const [us, dk, de, ca] = await service.createTaxRegions([
|
||||
{
|
||||
country_code: "US",
|
||||
default_tax_rate: { name: "US Default Rate", rate: 2 },
|
||||
},
|
||||
{
|
||||
country_code: "DK",
|
||||
default_tax_rate: { name: "Denmark Default Rate", rate: 25 },
|
||||
},
|
||||
{
|
||||
country_code: "DE",
|
||||
default_tax_rate: {
|
||||
code: "DE19",
|
||||
name: "Germany Default Rate",
|
||||
rate: 19,
|
||||
},
|
||||
},
|
||||
{
|
||||
country_code: "CA",
|
||||
default_tax_rate: { name: "Canada Default Rate", rate: 5 },
|
||||
},
|
||||
])
|
||||
|
||||
// Create province regions within the US
|
||||
const [cal, ny, fl, qc, bc] = await service.createTaxRegions([
|
||||
{
|
||||
country_code: "US",
|
||||
province_code: "CA",
|
||||
parent_id: us.id,
|
||||
default_tax_rate: {
|
||||
rate: 5,
|
||||
name: "CA Default Rate",
|
||||
code: "CADEFAULT",
|
||||
},
|
||||
},
|
||||
{
|
||||
country_code: "US",
|
||||
province_code: "NY",
|
||||
parent_id: us.id,
|
||||
default_tax_rate: {
|
||||
rate: 6,
|
||||
name: "NY Default Rate",
|
||||
code: "NYDEFAULT",
|
||||
},
|
||||
},
|
||||
{
|
||||
country_code: "US",
|
||||
province_code: "FL",
|
||||
parent_id: us.id,
|
||||
default_tax_rate: {
|
||||
rate: 4,
|
||||
name: "FL Default Rate",
|
||||
code: "FLDEFAULT",
|
||||
},
|
||||
},
|
||||
{
|
||||
country_code: "CA",
|
||||
province_code: "QC",
|
||||
parent_id: ca.id,
|
||||
default_tax_rate: {
|
||||
rate: 2,
|
||||
name: "QC Default Rate",
|
||||
code: "QCDEFAULT",
|
||||
},
|
||||
},
|
||||
{
|
||||
country_code: "CA",
|
||||
province_code: "BC",
|
||||
parent_id: ca.id,
|
||||
default_tax_rate: {
|
||||
rate: 2,
|
||||
name: "BC Default Rate",
|
||||
code: "BCDEFAULT",
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const [calProd, calType, deType, canProd, canType, qcType] =
|
||||
await service.create([
|
||||
{
|
||||
tax_region_id: cal.id,
|
||||
name: "CA Reduced Rate for Products",
|
||||
rate: 3,
|
||||
code: "CAREDUCE_PROD",
|
||||
},
|
||||
{
|
||||
tax_region_id: cal.id,
|
||||
name: "CA Reduced Rate for Product Type",
|
||||
rate: 1,
|
||||
code: "CAREDUCE_TYPE",
|
||||
},
|
||||
{
|
||||
tax_region_id: de.id,
|
||||
name: "Germany Reduced Rate for Product Type",
|
||||
rate: 7,
|
||||
code: "DEREDUCE_TYPE",
|
||||
},
|
||||
{
|
||||
tax_region_id: ca.id,
|
||||
name: "Canada Reduced Rate for Product",
|
||||
rate: 3,
|
||||
code: "CAREDUCE_PROD_CA",
|
||||
},
|
||||
{
|
||||
tax_region_id: ca.id,
|
||||
name: "Canada Reduced Rate for Product Type",
|
||||
rate: 3.5,
|
||||
code: "CAREDUCE_TYPE_CA",
|
||||
},
|
||||
{
|
||||
tax_region_id: qc.id,
|
||||
name: "QC Reduced Rate for Product Type",
|
||||
rate: 1,
|
||||
code: "QCREDUCE_TYPE",
|
||||
},
|
||||
])
|
||||
|
||||
// Create tax rate rules for specific products and product types
|
||||
await service.createTaxRateRules([
|
||||
{
|
||||
reference: "product",
|
||||
reference_id: "product_id_1",
|
||||
tax_rate_id: calProd.id,
|
||||
},
|
||||
{
|
||||
reference: "product",
|
||||
reference_id: "product_id_2",
|
||||
tax_rate_id: calProd.id,
|
||||
},
|
||||
{
|
||||
reference: "product",
|
||||
reference_id: "product_id_3",
|
||||
tax_rate_id: calProd.id,
|
||||
},
|
||||
{
|
||||
reference: "product_type",
|
||||
reference_id: "product_type_id_1",
|
||||
tax_rate_id: calType.id,
|
||||
},
|
||||
{
|
||||
reference: "product_type",
|
||||
reference_id: "product_type_id_2",
|
||||
tax_rate_id: deType.id,
|
||||
},
|
||||
{
|
||||
reference: "product",
|
||||
reference_id: "product_id_4",
|
||||
tax_rate_id: canProd.id,
|
||||
},
|
||||
{
|
||||
reference: "product_type",
|
||||
reference_id: "product_type_id_3",
|
||||
tax_rate_id: canType.id,
|
||||
},
|
||||
{
|
||||
reference: "product_type",
|
||||
reference_id: "product_type_id_3",
|
||||
tax_rate_id: qcType.id,
|
||||
},
|
||||
])
|
||||
|
||||
return {
|
||||
us: {
|
||||
country: us,
|
||||
children: {
|
||||
cal: {
|
||||
province: cal,
|
||||
overrides: {
|
||||
calProd,
|
||||
calType,
|
||||
},
|
||||
},
|
||||
ny: {
|
||||
province: ny,
|
||||
overrides: {},
|
||||
},
|
||||
fl: {
|
||||
province: fl,
|
||||
overrides: {},
|
||||
},
|
||||
},
|
||||
overrides: {},
|
||||
},
|
||||
dk: {
|
||||
country: dk,
|
||||
children: {},
|
||||
overrides: {},
|
||||
},
|
||||
de: {
|
||||
country: de,
|
||||
children: {},
|
||||
overrides: {
|
||||
deType,
|
||||
},
|
||||
},
|
||||
ca: {
|
||||
country: ca,
|
||||
children: {
|
||||
qc: {
|
||||
province: qc,
|
||||
overrides: {
|
||||
qcType,
|
||||
},
|
||||
},
|
||||
bc: {
|
||||
province: bc,
|
||||
overrides: {},
|
||||
},
|
||||
},
|
||||
overrides: {
|
||||
canProd,
|
||||
canType,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { initModules } from "medusa-test-utils"
|
||||
import { ITaxModuleService } from "@medusajs/types"
|
||||
import { Modules } from "@medusajs/modules-sdk"
|
||||
|
||||
import { MikroOrmWrapper } from "../utils"
|
||||
import { getInitModuleConfig } from "../utils/get-init-module-config"
|
||||
|
||||
jest.setTimeout(30000)
|
||||
|
||||
describe("TaxModuleService", function () {
|
||||
let service: ITaxModuleService
|
||||
let shutdownFunc: () => Promise<void>
|
||||
|
||||
beforeAll(async () => {
|
||||
const initModulesConfig = getInitModuleConfig()
|
||||
|
||||
const { medusaApp, shutdown } = await initModules(initModulesConfig)
|
||||
|
||||
service = medusaApp.modules[Modules.TAX]
|
||||
|
||||
shutdownFunc = shutdown
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await shutdownFunc()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await MikroOrmWrapper.setupDatabase()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await MikroOrmWrapper.clearDatabase()
|
||||
})
|
||||
|
||||
it("should create a tax region", async () => {
|
||||
const [region] = await service.createTaxRegions([
|
||||
{
|
||||
country_code: "US",
|
||||
default_tax_rate: {
|
||||
name: "Test Rate",
|
||||
rate: 0.2,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const [provinceRegion] = await service.createTaxRegions([
|
||||
{
|
||||
country_code: "US",
|
||||
province_code: "CA",
|
||||
parent_id: region.id,
|
||||
default_tax_rate: {
|
||||
name: "CA Rate",
|
||||
rate: 8.25,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const listedRegions = await service.listTaxRegions()
|
||||
expect(listedRegions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: region.id,
|
||||
country_code: "US",
|
||||
province_code: null,
|
||||
parent_id: null,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: provinceRegion.id,
|
||||
country_code: "US",
|
||||
province_code: "CA",
|
||||
parent_id: region.id,
|
||||
}),
|
||||
])
|
||||
)
|
||||
|
||||
const rates = await service.list()
|
||||
expect(rates).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
tax_region_id: region.id,
|
||||
rate: 0.2,
|
||||
name: "Test Rate",
|
||||
is_default: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tax_region_id: provinceRegion.id,
|
||||
rate: 8.25,
|
||||
name: "CA Rate",
|
||||
is_default: true,
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("should create a tax rate rule", async () => {
|
||||
const [region] = await service.createTaxRegions([
|
||||
{
|
||||
country_code: "US",
|
||||
default_tax_rate: {
|
||||
name: "Test Rate",
|
||||
rate: 0.2,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const rate = await service.create({
|
||||
tax_region_id: region.id,
|
||||
name: "Shipping Rate",
|
||||
rate: 8.23,
|
||||
})
|
||||
|
||||
await service.createTaxRateRules([
|
||||
{
|
||||
tax_rate_id: rate.id,
|
||||
reference: "product",
|
||||
reference_id: "prod_1234",
|
||||
},
|
||||
])
|
||||
|
||||
const listedRules = await service.listTaxRateRules(
|
||||
{},
|
||||
{
|
||||
relations: ["tax_rate"],
|
||||
}
|
||||
)
|
||||
expect(listedRules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
reference: "product",
|
||||
reference_id: "prod_1234",
|
||||
tax_rate: expect.objectContaining({
|
||||
tax_region_id: region.id,
|
||||
name: "Shipping Rate",
|
||||
rate: 8.23,
|
||||
}),
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
if (typeof process.env.DB_TEMP_NAME === "undefined") {
|
||||
const tempName = parseInt(process.env.JEST_WORKER_ID || "1")
|
||||
process.env.DB_TEMP_NAME = `medusa-tax-integration-${tempName}`
|
||||
}
|
||||
|
||||
process.env.MEDUSA_TAX_DB_SCHEMA = "public"
|
||||
@@ -1,3 +0,0 @@
|
||||
import { JestUtils } from "medusa-test-utils"
|
||||
|
||||
JestUtils.afterAllHookDropDatabase()
|
||||
@@ -1,12 +0,0 @@
|
||||
import { TestDatabaseUtils } from "medusa-test-utils"
|
||||
|
||||
import * as Models from "@models"
|
||||
|
||||
const mikroOrmEntities = Models as unknown as any[]
|
||||
|
||||
export const MikroOrmWrapper = TestDatabaseUtils.getMikroOrmWrapper({
|
||||
mikroOrmEntities,
|
||||
schema: process.env.MEDUSA_ORDER_DB_SCHEMA,
|
||||
})
|
||||
|
||||
export const DB_URL = TestDatabaseUtils.getDatabaseURL()
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Modules, ModulesDefinition } from "@medusajs/modules-sdk"
|
||||
|
||||
import { DB_URL } from "./database"
|
||||
|
||||
export function getInitModuleConfig() {
|
||||
const moduleOptions = {
|
||||
defaultAdapterOptions: {
|
||||
database: {
|
||||
clientUrl: DB_URL,
|
||||
schema: process.env.MEDUSA_TAX_DB_SCHEMA,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const injectedDependencies = {}
|
||||
|
||||
const modulesConfig_ = {
|
||||
[Modules.TAX]: {
|
||||
definition: ModulesDefinition[Modules.TAX],
|
||||
options: moduleOptions,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
injectedDependencies,
|
||||
modulesConfig: modulesConfig_,
|
||||
databaseConfig: {
|
||||
clientUrl: DB_URL,
|
||||
schema: process.env.MEDUSA_TAX_DB_SCHEMA,
|
||||
},
|
||||
joinerConfig: [],
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./database"
|
||||
export * from "./get-init-module-config"
|
||||
@@ -17,6 +17,4 @@ module.exports = {
|
||||
testEnvironment: `node`,
|
||||
moduleFileExtensions: [`js`, `ts`],
|
||||
modulePathIgnorePatterns: ["dist/"],
|
||||
setupFiles: ["<rootDir>/integration-tests/setup-env.js"],
|
||||
setupFilesAfterEnv: ["<rootDir>/integration-tests/setup.js"],
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"prepublishOnly": "cross-env NODE_ENV=production tsc --build && tsc-alias -p tsconfig.json",
|
||||
"build": "rimraf dist && tsc --build && tsc-alias -p tsconfig.json",
|
||||
"test": "jest --runInBand --bail --forceExit -- src/**/__tests__/**/*.ts",
|
||||
"test:integration": "jest --runInBand --forceExit -- integration-tests/**/__tests__/**/*.ts",
|
||||
"test:integration": "jest --runInBand --forceExit -- integration-tests/**/__tests__/**/*.spec.ts",
|
||||
"migration:generate": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:generate",
|
||||
"migration:initial": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create --initial",
|
||||
"migration:create": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,3 +99,55 @@ export interface FilterableTaxRateRuleProps
|
||||
updated_at?: OperatorMap<string>
|
||||
created_by?: string | string[] | OperatorMap<string>
|
||||
}
|
||||
export interface TaxableItemDTO {
|
||||
id: string
|
||||
product_id: string
|
||||
product_name?: string
|
||||
product_category_id?: string
|
||||
product_categories?: string[]
|
||||
product_sku?: string
|
||||
product_type?: string
|
||||
product_type_id?: string
|
||||
quantity?: number
|
||||
unit_price?: number
|
||||
currency_code?: string
|
||||
}
|
||||
|
||||
export interface TaxableShippingDTO {
|
||||
id: string
|
||||
shipping_option_id: string
|
||||
unit_price?: number
|
||||
currency_code?: string
|
||||
}
|
||||
|
||||
export interface TaxCalculationContext {
|
||||
address: {
|
||||
country_code: string
|
||||
province_code?: string | null
|
||||
address_1?: string
|
||||
address_2?: string | null
|
||||
city?: string
|
||||
postal_code?: string
|
||||
}
|
||||
customer?: {
|
||||
id: string
|
||||
email: string
|
||||
customer_groups: string[]
|
||||
}
|
||||
is_return?: boolean
|
||||
}
|
||||
|
||||
interface TaxLineDTO {
|
||||
rate_id: string
|
||||
rate: number | null
|
||||
code: string | null
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ItemTaxLineDTO extends TaxLineDTO {
|
||||
line_item_id: string
|
||||
}
|
||||
|
||||
export interface ShippingTaxLineDTO extends TaxLineDTO {
|
||||
shipping_line_id: string
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
TaxRegionDTO,
|
||||
TaxRateRuleDTO,
|
||||
FilterableTaxRateRuleProps,
|
||||
TaxableItemDTO,
|
||||
TaxCalculationContext,
|
||||
ItemTaxLineDTO,
|
||||
ShippingTaxLineDTO,
|
||||
TaxableShippingDTO,
|
||||
} from "./common"
|
||||
import {
|
||||
CreateTaxRateRuleDTO,
|
||||
@@ -64,4 +69,10 @@ export interface ITaxModuleService extends IModuleService {
|
||||
config?: FindConfig<TaxRateRuleDTO>,
|
||||
sharedContext?: Context
|
||||
): Promise<TaxRateRuleDTO[]>
|
||||
|
||||
getTaxLines(
|
||||
item: (TaxableItemDTO | TaxableShippingDTO)[],
|
||||
calculationContext: TaxCalculationContext,
|
||||
sharedContext?: Context
|
||||
): Promise<(ItemTaxLineDTO | ShippingTaxLineDTO)[]>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user