From 5977a38ef47a9be22a4150d6be76d7555568235b Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Sun, 18 Feb 2024 14:31:50 +0100 Subject: [PATCH] feat(tax): Add TaxRegion (#6421) **What** - Adds a TaxRegion entity. **For context: High-level design of the Tax module** - A TaxRegion scopes tax rates to a geographical place. - You can define tax regions on two levels: country-level, province-level (this corresponds to state in US contexts) - Each Tax Region can have a default Tax Rate. - [not yet done] - Each Tax Region can also have granularly defined tax rates for different products and shipping rates. For example, California can have a base rate for default products, but a reduced rate for groceries. - Tax Rates specify if they can be combined with other rates - it's always the lowest level rate that wins. The above allows a merchant to define their tax settings along the lines of this: - Denmark (Region) - Default rate: 25% (TaxRate) - Germany (Region) - Default rate: 19% (TaxRate) - Reduced rate (books): 9% (TaxRate w. rule) - United States (Region) - Default rate: 0% (TaxRate) - California: (Region) - Default rate: 7.25% (TaxRate) - Arkansas: (Region) - Default rate: 6.5% - Reduced rate (groceries): 0.125% (TaxRate w. rule) The TaxModule can then receive a list of products and the shipping address to determine what tax rates apply to the line items. --- .../tax/integration-tests/__tests__/index.ts | 96 ++++++++++++++++++- packages/tax/src/models/index.ts | 1 + packages/tax/src/models/tax-rate.ts | 40 +++++++- packages/tax/src/models/tax-region.ts | 96 +++++++++++++++++++ .../tax/src/services/tax-module-service.ts | 64 +++++++++++-- packages/types/src/tax/common.ts | 26 +++++ packages/types/src/tax/mutations.ts | 16 ++++ packages/types/src/tax/service.ts | 22 ++++- 8 files changed, 346 insertions(+), 15 deletions(-) create mode 100644 packages/tax/src/models/tax-region.ts diff --git a/packages/tax/integration-tests/__tests__/index.ts b/packages/tax/integration-tests/__tests__/index.ts index 333c84c1dd..7632b8b28b 100644 --- a/packages/tax/integration-tests/__tests__/index.ts +++ b/packages/tax/integration-tests/__tests__/index.ts @@ -1,5 +1,95 @@ -describe("noop", function () { - it("should run", function () { - expect(true).toBe(true) +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, + }), + ]) + ) }) }) diff --git a/packages/tax/src/models/index.ts b/packages/tax/src/models/index.ts index 65b32f2970..03c4263f3b 100644 --- a/packages/tax/src/models/index.ts +++ b/packages/tax/src/models/index.ts @@ -1 +1,2 @@ export { default as TaxRate } from "./tax-rate" +export { default as TaxRegion } from "./tax-region" diff --git a/packages/tax/src/models/tax-rate.ts b/packages/tax/src/models/tax-rate.ts index c45df9406d..399e61b27f 100644 --- a/packages/tax/src/models/tax-rate.ts +++ b/packages/tax/src/models/tax-rate.ts @@ -1,17 +1,37 @@ import { DAL } from "@medusajs/types" -import { generateEntityId } from "@medusajs/utils" +import { + createPsqlIndexStatementHelper, + generateEntityId, +} from "@medusajs/utils" import { BeforeCreate, + Cascade, Entity, + ManyToOne, OnInit, OptionalProps, PrimaryKey, Property, } from "@mikro-orm/core" +import TaxRegion from "./tax-region" type OptionalTaxRateProps = DAL.EntityDateColumns -@Entity({ tableName: "tax_rate" }) +const TABLE_NAME = "tax_rate" + +const taxRegionIdIndexName = "IDX_tax_rate_tax_region_id" + +const singleDefaultRegionIndexName = "IDX_single_default_region" +const singleDefaultRegionIndexStatement = createPsqlIndexStatementHelper({ + name: singleDefaultRegionIndexName, + tableName: TABLE_NAME, + columns: "tax_region_id", + unique: true, + where: "is_default = true", +}) + +@singleDefaultRegionIndexStatement.MikroORMIndex() +@Entity({ tableName: TABLE_NAME }) export default class TaxRate { [OptionalProps]: OptionalTaxRateProps @@ -27,6 +47,22 @@ export default class TaxRate { @Property({ columnType: "text" }) name: string + @Property({ columnType: "bool", default: false }) + is_default = false + + @Property({ columnType: "bool", default: false }) + is_combinable = false + + @Property({ columnType: "text" }) + tax_region_id: string + + @ManyToOne(() => TaxRegion, { + fieldName: "tax_region_id", + index: taxRegionIdIndexName, + cascade: [Cascade.REMOVE, Cascade.PERSIST], + }) + tax_region: TaxRegion + @Property({ columnType: "jsonb", nullable: true }) metadata: Record | null = null diff --git a/packages/tax/src/models/tax-region.ts b/packages/tax/src/models/tax-region.ts new file mode 100644 index 0000000000..5a436ddedf --- /dev/null +++ b/packages/tax/src/models/tax-region.ts @@ -0,0 +1,96 @@ +import { DAL } from "@medusajs/types" +import { + createPsqlIndexStatementHelper, + generateEntityId, +} from "@medusajs/utils" +import { + BeforeCreate, + Collection, + Entity, + OnInit, + OptionalProps, + PrimaryKey, + Property, + OneToMany, + ManyToOne, + Check, + Cascade, +} from "@mikro-orm/core" +import TaxRate from "./tax-rate" + +type OptionalTaxRegionProps = DAL.EntityDateColumns + +const TABLE_NAME = "tax_region" + +const countryCodeProvinceIndexName = "IDX_tax_region_unique_country_province" +const countryCodeProvinceIndexStatement = createPsqlIndexStatementHelper({ + name: countryCodeProvinceIndexName, + tableName: TABLE_NAME, + columns: ["country_code", "province_code"], + unique: true, +}) + +const taxRegionCountryTopLevelCheckName = "CK_tax_region_country_top_level" +@Check({ + name: taxRegionCountryTopLevelCheckName, + expression: `parent_id IS NULL OR province_code IS NOT NULL`, +}) +@countryCodeProvinceIndexStatement.MikroORMIndex() +@Entity({ tableName: TABLE_NAME }) +export default class TaxRegion { + [OptionalProps]: OptionalTaxRegionProps + + @PrimaryKey({ columnType: "text" }) + id!: string + + @Property({ columnType: "text" }) + country_code: string + + @Property({ columnType: "text", nullable: true }) + province_code: string | null = null + + @Property({ columnType: "text", nullable: true }) + parent_id: string | null = null + + @ManyToOne(() => TaxRegion, { + index: "IDX_tax_region_parent_id", + cascade: [Cascade.PERSIST], + onDelete: "set null", + nullable: true, + }) + parent: TaxRegion + + @OneToMany(() => TaxRate, (label) => label.tax_region) + tax_rates = new Collection(this) + + @Property({ columnType: "jsonb", nullable: true }) + metadata: Record | null = null + + @Property({ + onCreate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + created_at: Date + + @Property({ + onCreate: () => new Date(), + onUpdate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + updated_at: Date + + @Property({ columnType: "text", nullable: true }) + created_by: string | null = null + + @BeforeCreate() + onCreate() { + this.id = generateEntityId(this.id, "txreg") + } + + @OnInit() + onInit() { + this.id = generateEntityId(this.id, "txreg") + } +} diff --git a/packages/tax/src/services/tax-module-service.ts b/packages/tax/src/services/tax-module-service.ts index fbc8a9359b..668226fbb5 100644 --- a/packages/tax/src/services/tax-module-service.ts +++ b/packages/tax/src/services/tax-module-service.ts @@ -1,7 +1,7 @@ import { Context, DAL, - ITaxRateModuleService, + ITaxModuleService, InternalModuleDeclaration, ModuleJoinerConfig, ModulesSdkTypes, @@ -13,27 +13,32 @@ import { MedusaContext, ModulesSdkUtils, } from "@medusajs/utils" -import { TaxRate } from "@models" +import { TaxRate, TaxRegion } from "@models" import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" type InjectedDependencies = { baseRepository: DAL.RepositoryService taxRateService: ModulesSdkTypes.InternalModuleService + taxRegionService: ModulesSdkTypes.InternalModuleService } -export default class TaxModuleService +export default class TaxModuleService< + TTaxRate extends TaxRate = TaxRate, + TTaxRegion extends TaxRegion = TaxRegion + > extends ModulesSdkUtils.abstractModuleServiceFactory< InjectedDependencies, TaxTypes.TaxRateDTO, - {} - >(TaxRate, [], entityNameToLinkableKeysMap) - implements ITaxRateModuleService + { TaxRegion: { dto: TaxTypes.TaxRegionDTO } } + >(TaxRate, [TaxRegion], entityNameToLinkableKeysMap) + implements ITaxModuleService { protected baseRepository_: DAL.RepositoryService protected taxRateService_: ModulesSdkTypes.InternalModuleService + protected taxRegionService_: ModulesSdkTypes.InternalModuleService constructor( - { baseRepository, taxRateService }: InjectedDependencies, + { baseRepository, taxRateService, taxRegionService }: InjectedDependencies, protected readonly moduleDeclaration: InternalModuleDeclaration ) { // @ts-ignore @@ -41,6 +46,7 @@ export default class TaxModuleService this.baseRepository_ = baseRepository this.taxRateService_ = taxRateService + this.taxRegionService_ = taxRegionService } __joinerConfig(): ModuleJoinerConfig { @@ -78,4 +84,48 @@ export default class TaxModuleService ) { return await this.taxRateService_.create(data, sharedContext) } + + async createTaxRegions( + data: TaxTypes.CreateTaxRegionDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + // TODO: check that country_code === parent.country_code + const [defaultRates, regionData] = data.reduce( + (acc, region) => { + const { default_tax_rate, ...rest } = region + acc[0].push({ + ...default_tax_rate, + is_default: true, + created_by: region.created_by, + }) + acc[1].push(rest) + return acc + }, + [[], []] as [ + Omit[], + Partial[] + ] + ) + + const regions = await this.taxRegionService_.create( + regionData, + sharedContext + ) + + const rates = regions.map((region, i) => { + return { + ...defaultRates[i], + tax_region_id: region.id, + } + }) + + await this.create(rates, sharedContext) + + return await this.baseRepository_.serialize( + regions, + { + populate: true, + } + ) + } } diff --git a/packages/types/src/tax/common.ts b/packages/types/src/tax/common.ts index 12935161cf..672e6b524e 100644 --- a/packages/types/src/tax/common.ts +++ b/packages/types/src/tax/common.ts @@ -47,3 +47,29 @@ export interface FilterableTaxRateProps updated_at?: OperatorMap created_by?: string | string[] | OperatorMap } + +export interface TaxRegionDTO { + id: string + country_code: string + province_code: string | null + parent_id: string | null + metadata: Record | null + created_at: string | Date + updated_at: string | Date + created_by: string | null +} + +export interface FilterableTaxRegionProps + extends BaseFilterable { + id?: string | string[] + country_code?: string | string[] | OperatorMap + province_code?: string | string[] | OperatorMap + parent_id?: string | string[] | OperatorMap + metadata?: + | Record + | Record[] + | OperatorMap> + created_at?: OperatorMap + updated_at?: OperatorMap + created_by?: string | string[] | OperatorMap +} diff --git a/packages/types/src/tax/mutations.ts b/packages/types/src/tax/mutations.ts index 9541ee7553..7df24a4fb5 100644 --- a/packages/types/src/tax/mutations.ts +++ b/packages/types/src/tax/mutations.ts @@ -1,7 +1,23 @@ export interface CreateTaxRateDTO { + tax_region_id: string rate?: number | null code?: string | null name: string + is_default?: boolean created_by?: string metadata?: Record } + +export interface CreateTaxRegionDTO { + country_code: string + province_code?: string | null + parent_id?: string | null + metadata?: Record + created_by?: string + default_tax_rate: { + rate?: number | null + code?: string | null + name: string + metadata?: Record + } +} diff --git a/packages/types/src/tax/service.ts b/packages/types/src/tax/service.ts index 77ad1b8529..e59c40b390 100644 --- a/packages/types/src/tax/service.ts +++ b/packages/types/src/tax/service.ts @@ -1,10 +1,15 @@ import { FindConfig } from "../common" import { IModuleService } from "../modules-sdk" import { Context } from "../shared-context" -import { FilterableTaxRateProps, TaxRateDTO } from "./common" -import { CreateTaxRateDTO } from "./mutations" +import { + FilterableTaxRegionProps, + FilterableTaxRateProps, + TaxRateDTO, + TaxRegionDTO, +} from "./common" +import { CreateTaxRateDTO, CreateTaxRegionDTO } from "./mutations" -export interface ITaxRateModuleService extends IModuleService { +export interface ITaxModuleService extends IModuleService { retrieve( taxRateId: string, config?: FindConfig, @@ -31,4 +36,15 @@ export interface ITaxRateModuleService extends IModuleService { delete(taxRateIds: string[], sharedContext?: Context): Promise delete(taxRateId: string, sharedContext?: Context): Promise + + createTaxRegions( + data: CreateTaxRegionDTO[], + sharedContext?: Context + ): Promise + + listTaxRegions( + filters?: FilterableTaxRegionProps, + config?: FindConfig, + sharedContext?: Context + ): Promise }