From 84174f2766b9952ed9b762b2f892bfcd6586a013 Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Tue, 20 Feb 2024 19:23:12 +0100 Subject: [PATCH 1/6] feat(tax): get tax lines --- .../integration-tests/__tests__/index.spec.ts | 546 ++++++++++++++++++ .../tax/integration-tests/__tests__/index.ts | 141 ----- packages/tax/integration-tests/setup-env.js | 6 - packages/tax/integration-tests/setup.js | 3 - .../tax/integration-tests/utils/database.ts | 12 - .../utils/get-init-module-config.ts | 33 -- packages/tax/integration-tests/utils/index.ts | 2 - packages/tax/jest.config.js | 2 - packages/tax/package.json | 2 +- packages/tax/src/models/tax-rate.ts | 6 + .../tax/src/services/tax-module-service.ts | 144 ++++- packages/types/src/tax/common.ts | 55 ++ packages/types/src/tax/service.ts | 11 + 13 files changed, 762 insertions(+), 201 deletions(-) create mode 100644 packages/tax/integration-tests/__tests__/index.spec.ts delete mode 100644 packages/tax/integration-tests/__tests__/index.ts delete mode 100644 packages/tax/integration-tests/setup-env.js delete mode 100644 packages/tax/integration-tests/setup.js delete mode 100644 packages/tax/integration-tests/utils/database.ts delete mode 100644 packages/tax/integration-tests/utils/get-init-module-config.ts delete mode 100644 packages/tax/integration-tests/utils/index.ts diff --git a/packages/tax/integration-tests/__tests__/index.spec.ts b/packages/tax/integration-tests/__tests__/index.spec.ts new file mode 100644 index 0000000000000..6d8ac4140428a --- /dev/null +++ b/packages/tax/integration-tests/__tests__/index.spec.ts @@ -0,0 +1,546 @@ +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: ({ + MikroOrmWrapper, + service, + }: SuiteOptions) => { + 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, + }, + }, + } +} diff --git a/packages/tax/integration-tests/__tests__/index.ts b/packages/tax/integration-tests/__tests__/index.ts deleted file mode 100644 index fab2f165660a5..0000000000000 --- a/packages/tax/integration-tests/__tests__/index.ts +++ /dev/null @@ -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 - - 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, - }), - }), - ]) - ) - }) -}) diff --git a/packages/tax/integration-tests/setup-env.js b/packages/tax/integration-tests/setup-env.js deleted file mode 100644 index fda322997fc74..0000000000000 --- a/packages/tax/integration-tests/setup-env.js +++ /dev/null @@ -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" diff --git a/packages/tax/integration-tests/setup.js b/packages/tax/integration-tests/setup.js deleted file mode 100644 index 43f99aab4ac94..0000000000000 --- a/packages/tax/integration-tests/setup.js +++ /dev/null @@ -1,3 +0,0 @@ -import { JestUtils } from "medusa-test-utils" - -JestUtils.afterAllHookDropDatabase() diff --git a/packages/tax/integration-tests/utils/database.ts b/packages/tax/integration-tests/utils/database.ts deleted file mode 100644 index dfd64670826c5..0000000000000 --- a/packages/tax/integration-tests/utils/database.ts +++ /dev/null @@ -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() diff --git a/packages/tax/integration-tests/utils/get-init-module-config.ts b/packages/tax/integration-tests/utils/get-init-module-config.ts deleted file mode 100644 index 59e8fd4c4b497..0000000000000 --- a/packages/tax/integration-tests/utils/get-init-module-config.ts +++ /dev/null @@ -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: [], - } -} diff --git a/packages/tax/integration-tests/utils/index.ts b/packages/tax/integration-tests/utils/index.ts deleted file mode 100644 index ba28fb552380b..0000000000000 --- a/packages/tax/integration-tests/utils/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./database" -export * from "./get-init-module-config" diff --git a/packages/tax/jest.config.js b/packages/tax/jest.config.js index 456054fe8ae27..58c887c1c35d7 100644 --- a/packages/tax/jest.config.js +++ b/packages/tax/jest.config.js @@ -17,6 +17,4 @@ module.exports = { testEnvironment: `node`, moduleFileExtensions: [`js`, `ts`], modulePathIgnorePatterns: ["dist/"], - setupFiles: ["/integration-tests/setup-env.js"], - setupFilesAfterEnv: ["/integration-tests/setup.js"], } diff --git a/packages/tax/package.json b/packages/tax/package.json index 8c28cd694375f..a268a8580c72a 100644 --- a/packages/tax/package.json +++ b/packages/tax/package.json @@ -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", diff --git a/packages/tax/src/models/tax-rate.ts b/packages/tax/src/models/tax-rate.ts index 399e61b27f1bf..2ab8d22964f5c 100644 --- a/packages/tax/src/models/tax-rate.ts +++ b/packages/tax/src/models/tax-rate.ts @@ -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(this) + @Property({ columnType: "jsonb", nullable: true }) metadata: Record | null = null diff --git a/packages/tax/src/services/tax-module-service.ts b/packages/tax/src/services/tax-module-service.ts index 0fa882e10b2bc..b6ddc1f339c11 100644 --- a/packages/tax/src/services/tax-module-service.ts +++ b/packages/tax/src/services/tax-module-service.ts @@ -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,145 @@ export default class TaxModuleService< ) { return await this.taxRateRuleService_.create(data, sharedContext) } + + 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 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, + }, + ] + + const rates = await this.taxRateService_.list( + { + $and: [ + { tax_region_id: { $in: regions.map((r) => r.id) } }, + { $or: [{ is_default: true }, { rules: { $or: ruleQuery } }] }, + ], + }, + { relations: ["rules", "tax_region"] }, + sharedContext + ) + + const prioritizedRates = this.prioritizeRates(rates, item) + + const rate = prioritizedRates[0] + const toReturn = { + rate_id: rate.id, + rate: rate.rate, + code: rate.code, + name: rate.name, + } + + if (isShipping) { + return [{ shipping_line_id: item.id, ...toReturn }] + } + + return [{ line_item_id: item.id, ...toReturn }] + }) + ) + + return toReturn.flat() + } + + private prioritizeRates( + rates: TTaxRate[], + item: TaxTypes.TaxableItemDTO | TaxTypes.TaxableShippingDTO + ) { + const isShipping = "shipping_option_id" in item + + const decoratedRates: (TTaxRate & { + priority_score: number + })[] = rates.map((rate) => { + let isProductMatch = false + let isProductTypeMatch = false + if (rate.rules.length !== 0) { + 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.length !== 0) { + if (matchingRules.some((rule) => rule.reference === "product")) { + isProductMatch = true + } else { + isProductTypeMatch = true + } + } + } + + const isProvince = rate.tax_region.province_code !== null + const isDefault = rate.is_default + + if ((isShipping || isProductMatch) && isProvince) { + return { ...rate, priority_score: 1 } + } + if (isProductTypeMatch && isProvince) { + return { ...rate, priority_score: 2 } + } + if (isDefault && isProvince) { + return { ...rate, priority_score: 3 } + } + if (isProductMatch && !isProvince) { + return { ...rate, priority_score: 4 } + } + if (isProductTypeMatch && !isProvince) { + return { ...rate, priority_score: 5 } + } + if (isDefault && !isProvince) { + return { ...rate, priority_score: 6 } + } + + return { ...rate, priority_score: 7 } + }) + + return decoratedRates.sort( + (a, b) => (a as any).priority_score - (b as any).priority_score + ) + } } diff --git a/packages/types/src/tax/common.ts b/packages/types/src/tax/common.ts index af2e7a059b36c..75eeffa6f2f31 100644 --- a/packages/types/src/tax/common.ts +++ b/packages/types/src/tax/common.ts @@ -99,3 +99,58 @@ export interface FilterableTaxRateRuleProps updated_at?: OperatorMap created_by?: string | string[] | OperatorMap } +// HEAD +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 +} +// +//origin/develop diff --git a/packages/types/src/tax/service.ts b/packages/types/src/tax/service.ts index 749eeebddedc1..0d16599496145 100644 --- a/packages/types/src/tax/service.ts +++ b/packages/types/src/tax/service.ts @@ -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, sharedContext?: Context ): Promise + + getTaxLines( + item: (TaxableItemDTO | TaxableShippingDTO)[], + calculationContext: TaxCalculationContext, + sharedContext?: Context + ): Promise<(ItemTaxLineDTO | ShippingTaxLineDTO)[]> } From dd941f4260ae544d0118233db4090791b01301cb Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Tue, 20 Feb 2024 19:25:25 +0100 Subject: [PATCH 2/6] fix: merge cleanup --- packages/types/src/tax/common.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/types/src/tax/common.ts b/packages/types/src/tax/common.ts index 75eeffa6f2f31..cad17d34b50d2 100644 --- a/packages/types/src/tax/common.ts +++ b/packages/types/src/tax/common.ts @@ -99,7 +99,6 @@ export interface FilterableTaxRateRuleProps updated_at?: OperatorMap created_by?: string | string[] | OperatorMap } -// HEAD export interface TaxableItemDTO { id: string product_id: string @@ -152,5 +151,3 @@ export interface ItemTaxLineDTO extends TaxLineDTO { export interface ShippingTaxLineDTO extends TaxLineDTO { shipping_line_id: string } -// -//origin/develop From 9fc49177ad883ca8bdfc6d729a8331b7afbc6415 Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Wed, 21 Feb 2024 18:43:56 +0100 Subject: [PATCH 3/6] fix: address pr comments --- .../tax/src/services/tax-module-service.ts | 169 ++++++++++-------- 1 file changed, 97 insertions(+), 72 deletions(-) diff --git a/packages/tax/src/services/tax-module-service.ts b/packages/tax/src/services/tax-module-service.ts index b6ddc1f339c11..3fc9f53e128bb 100644 --- a/packages/tax/src/services/tax-module-service.ts +++ b/packages/tax/src/services/tax-module-service.ts @@ -168,6 +168,7 @@ export default class TaxModuleService< return await this.taxRateRuleService_.create(data, sharedContext) } + @InjectManager("baseRepository_") async getTaxLines( items: (TaxTypes.TaxableItemDTO | TaxTypes.TaxableShippingDTO)[], calculationContext: TaxTypes.TaxCalculationContext, @@ -193,35 +194,15 @@ export default class TaxModuleService< const toReturn = await promiseAll( items.map(async (item) => { 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, - }, - ] - + const rateQuery = this.getTaxRateQueryForItem( + item, + regions.map((r) => r.id) + ) const rates = await this.taxRateService_.list( - { - $and: [ - { tax_region_id: { $in: regions.map((r) => r.id) } }, - { $or: [{ is_default: true }, { rules: { $or: ruleQuery } }] }, - ], - }, - { relations: ["rules", "tax_region"] }, + rateQuery, + { relations: ["tax_region", "rules"] }, sharedContext ) - const prioritizedRates = this.prioritizeRates(rates, item) const rate = prioritizedRates[0] @@ -243,65 +224,109 @@ export default class TaxModuleService< return toReturn.flat() } - private prioritizeRates( - rates: TTaxRate[], + 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) => { - let isProductMatch = false - let isProductTypeMatch = false - if (rate.rules.length !== 0) { - 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.length !== 0) { - if (matchingRules.some((rule) => rule.reference === "product")) { - isProductMatch = true - } else { - isProductTypeMatch = true - } - } - } + const { isProductMatch, isProductTypeMatch, isShippingMatch } = + this.checkRuleMatches(rate, item) const isProvince = rate.tax_region.province_code !== null const isDefault = rate.is_default - if ((isShipping || isProductMatch) && isProvince) { - return { ...rate, priority_score: 1 } - } - if (isProductTypeMatch && isProvince) { - return { ...rate, priority_score: 2 } - } - if (isDefault && isProvince) { - return { ...rate, priority_score: 3 } - } - if (isProductMatch && !isProvince) { - return { ...rate, priority_score: 4 } - } - if (isProductTypeMatch && !isProvince) { - return { ...rate, priority_score: 5 } - } - if (isDefault && !isProvince) { - return { ...rate, priority_score: 6 } + const decoratedRate = { + ...rate, + priority_score: 7, } - return { ...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( From f8bdb643a2fe010c28a6be10784289558588e4b9 Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Thu, 22 Feb 2024 09:42:28 +0100 Subject: [PATCH 4/6] feat(tax): add combinable rate --- .../tax/src/services/tax-module-service.ts | 95 ++++++++++++++----- 1 file changed, 70 insertions(+), 25 deletions(-) diff --git a/packages/tax/src/services/tax-module-service.ts b/packages/tax/src/services/tax-module-service.ts index 3fc9f53e128bb..e2f21f69d2fc4 100644 --- a/packages/tax/src/services/tax-module-service.ts +++ b/packages/tax/src/services/tax-module-service.ts @@ -187,43 +187,88 @@ export default class TaxModuleService< }, ], }, - {}, + { select: ["id"] }, sharedContext ) const toReturn = await promiseAll( - items.map(async (item) => { - const isShipping = "shipping_option_id" in item - const rateQuery = this.getTaxRateQueryForItem( + items.map((item) => + this.getTaxRatesForItem( item, - regions.map((r) => r.id) - ) - const rates = await this.taxRateService_.list( - rateQuery, - { relations: ["tax_region", "rules"] }, + regions.map((r) => r.id), sharedContext ) - const prioritizedRates = this.prioritizeRates(rates, item) - - const rate = prioritizedRates[0] - const toReturn = { - rate_id: rate.id, - rate: rate.rate, - code: rate.code, - name: rate.name, - } - - if (isShipping) { - return [{ shipping_line_id: item.id, ...toReturn }] - } - - return [{ line_item_id: item.id, ...toReturn }] - }) + ) ) return toReturn.flat() } + private async getTaxRatesForItem( + item: TaxTypes.TaxableItemDTO | TaxTypes.TaxableShippingDTO, + regionIds: string[], + sharedContext: Context + ): Promise<(TaxTypes.ItemTaxLineDTO | TaxTypes.ShippingTaxLineDTO)[]> { + const rateQuery = this.getTaxRateQueryForItem(item, regionIds) + const rates = await this.taxRateService_.list( + rateQuery, + { relations: ["tax_region", "rules"] }, + sharedContext + ) + + 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[] From 330c18fdb67900a27719285f4c9067f3a81bc644 Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Thu, 22 Feb 2024 11:21:25 +0100 Subject: [PATCH 5/6] wip --- packages/tax/integration-tests/__tests__/index.spec.ts | 2 +- packages/tax/src/services/tax-module-service.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/tax/integration-tests/__tests__/index.spec.ts b/packages/tax/integration-tests/__tests__/index.spec.ts index 6d8ac4140428a..eca8c2f3c4c74 100644 --- a/packages/tax/integration-tests/__tests__/index.spec.ts +++ b/packages/tax/integration-tests/__tests__/index.spec.ts @@ -197,7 +197,7 @@ moduleIntegrationTestRunner({ ]) }) - it("applies default province rules when no specific product or product type rule matches", async () => { + it.only("applies default province rules when no specific product or product type rule matches", async () => { await setupTaxStructure(service) const item = { id: "item_test", diff --git a/packages/tax/src/services/tax-module-service.ts b/packages/tax/src/services/tax-module-service.ts index e2f21f69d2fc4..1f782a5ae5870 100644 --- a/packages/tax/src/services/tax-module-service.ts +++ b/packages/tax/src/services/tax-module-service.ts @@ -212,7 +212,9 @@ export default class TaxModuleService< const rateQuery = this.getTaxRateQueryForItem(item, regionIds) const rates = await this.taxRateService_.list( rateQuery, - { relations: ["tax_region", "rules"] }, + { + relations: ["tax_region", "rules"], + }, sharedContext ) @@ -221,6 +223,7 @@ export default class TaxModuleService< } const prioritizedRates = this.prioritizeRates(rates, item) + console.log(prioritizedRates[0].tax_region) const rate = prioritizedRates[0] const ratesToReturn = [this.buildRateForItem(rate, item)] From 4e9efa8258f81c5afcfdd11d8718c1bce9ff5f60 Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Thu, 22 Feb 2024 11:39:29 +0100 Subject: [PATCH 6/6] fix --- .../integration-tests/__tests__/index.spec.ts | 7 ++--- .../tax/src/services/tax-module-service.ts | 31 ++++++++----------- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/packages/tax/integration-tests/__tests__/index.spec.ts b/packages/tax/integration-tests/__tests__/index.spec.ts index eca8c2f3c4c74..3d4869bfeecc9 100644 --- a/packages/tax/integration-tests/__tests__/index.spec.ts +++ b/packages/tax/integration-tests/__tests__/index.spec.ts @@ -6,10 +6,7 @@ jest.setTimeout(30000) moduleIntegrationTestRunner({ moduleName: Modules.TAX, - testSuite: ({ - MikroOrmWrapper, - service, - }: SuiteOptions) => { + testSuite: ({ service }: SuiteOptions) => { describe("TaxModuleService", function () { it("should create a tax region", async () => { const [region] = await service.createTaxRegions([ @@ -197,7 +194,7 @@ moduleIntegrationTestRunner({ ]) }) - it.only("applies default province rules when no specific product or product type rule matches", async () => { + it("applies default province rules when no specific product or product type rule matches", async () => { await setupTaxStructure(service) const item = { id: "item_test", diff --git a/packages/tax/src/services/tax-module-service.ts b/packages/tax/src/services/tax-module-service.ts index 1f782a5ae5870..ab425256b88cd 100644 --- a/packages/tax/src/services/tax-module-service.ts +++ b/packages/tax/src/services/tax-module-service.ts @@ -187,18 +187,24 @@ export default class TaxModuleService< }, ], }, - { select: ["id"] }, + {}, sharedContext ) const toReturn = await promiseAll( - items.map((item) => - this.getTaxRatesForItem( - item, - regions.map((r) => r.id), + 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() @@ -206,24 +212,13 @@ export default class TaxModuleService< private async getTaxRatesForItem( item: TaxTypes.TaxableItemDTO | TaxTypes.TaxableShippingDTO, - regionIds: string[], - sharedContext: Context + rates: TTaxRate[] ): Promise<(TaxTypes.ItemTaxLineDTO | TaxTypes.ShippingTaxLineDTO)[]> { - const rateQuery = this.getTaxRateQueryForItem(item, regionIds) - const rates = await this.taxRateService_.list( - rateQuery, - { - relations: ["tax_region", "rules"], - }, - sharedContext - ) - if (!rates.length) { return [] } const prioritizedRates = this.prioritizeRates(rates, item) - console.log(prioritizedRates[0].tax_region) const rate = prioritizedRates[0] const ratesToReturn = [this.buildRateForItem(rate, item)]