diff --git a/packages/tax/integration-tests/__tests__/index.spec.ts b/packages/tax/integration-tests/__tests__/index.spec.ts index 27c96a5df8..261f946eb4 100644 --- a/packages/tax/integration-tests/__tests__/index.spec.ts +++ b/packages/tax/integration-tests/__tests__/index.spec.ts @@ -316,6 +316,33 @@ moduleIntegrationTestRunner({ expect(rates).toEqual([]) }) + it("should soft delete tax rate", async () => { + const region = await service.createTaxRegions({ + country_code: "US", + }) + + const taxRate = await service.create({ + tax_region_id: region.id, + value: 10, + code: "test", + name: "test", + }) + + await service.softDelete([taxRate.id]) + + const rates = await service.list( + { tax_region_id: region.id }, + { withDeleted: true } + ) + + expect(rates).toEqual([ + expect.objectContaining({ + id: taxRate.id, + deleted_at: expect.any(Date), + }), + ]) + }) + it("should delete a tax region and its rates", async () => { const region = await service.createTaxRegions({ country_code: "US", @@ -342,6 +369,47 @@ moduleIntegrationTestRunner({ expect(rates).toEqual([]) }) + it("should soft delete a tax region and its rates", async () => { + const region = await service.createTaxRegions({ + country_code: "US", + default_tax_rate: { + value: 2, + code: "test", + name: "default test", + }, + }) + + await service.create({ + tax_region_id: region.id, + value: 10, + code: "test", + name: "test", + }) + + await service.softDeleteTaxRegions([region.id]) + + const taxRegions = await service.listTaxRegions( + {}, + { withDeleted: true } + ) + const rates = await service.list({}, { withDeleted: true }) + + expect(taxRegions).toEqual([ + expect.objectContaining({ + id: region.id, + deleted_at: expect.any(Date), + }), + ]) + expect(rates).toEqual([ + expect.objectContaining({ + deleted_at: expect.any(Date), + }), + expect.objectContaining({ + deleted_at: expect.any(Date), + }), + ]) + }) + it("should delete a tax rate and its rules", async () => { const region = await service.createTaxRegions({ country_code: "US", @@ -369,6 +437,130 @@ moduleIntegrationTestRunner({ expect(rules).toEqual([]) }) + it("should soft delete a tax rate and its rules", async () => { + const region = await service.createTaxRegions({ + country_code: "US", + }) + + const rate = await service.create({ + tax_region_id: region.id, + value: 10, + code: "test", + name: "test", + rules: [ + { reference: "product", reference_id: "product_id_1" }, + { reference: "product_type", reference_id: "product_type_id" }, + ], + }) + + await service.softDelete(rate.id) + + const taxRegions = await service.listTaxRegions( + {}, + { withDeleted: true } + ) + const rates = await service.list({}, { withDeleted: true }) + const rules = await service.listTaxRateRules({}, { withDeleted: true }) + + expect(taxRegions).toEqual([ + expect.objectContaining({ id: region.id, deleted_at: null }), + ]) + expect(rates).toEqual([ + expect.objectContaining({ + id: rate.id, + deleted_at: expect.any(Date), + }), + ]) + expect(rules).toEqual([ + expect.objectContaining({ + tax_rate_id: rate.id, + deleted_at: expect.any(Date), + }), + expect.objectContaining({ + tax_rate_id: rate.id, + deleted_at: expect.any(Date), + }), + ]) + }) + + it("should soft delete a tax rule", async () => { + const region = await service.createTaxRegions({ + country_code: "US", + }) + + const rate = await service.create({ + tax_region_id: region.id, + value: 10, + code: "test", + name: "test", + }) + + const [ruleOne, ruleTwo] = await service.createTaxRateRules([ + { + tax_rate_id: rate.id, + reference: "product", + reference_id: "product_id_1", + }, + { + tax_rate_id: rate.id, + reference: "product_type", + reference_id: "product_type_id", + }, + ]) + + await service.softDeleteTaxRateRules([ruleOne.id]) + + const rules = await service.listTaxRateRules({}, { withDeleted: true }) + expect(rules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: ruleOne.id, + deleted_at: expect.any(Date), + }), + expect.objectContaining({ + id: ruleTwo.id, + deleted_at: null, + }), + ]) + ) + + const rateWithRules = await service.retrieve(rate.id, { + relations: ["rules"], + }) + expect(rateWithRules.rules.length).toBe(1) + + // should be possible to add the rule back again + await service.createTaxRateRules({ + tax_rate_id: rate.id, + reference: ruleOne.reference, + reference_id: ruleOne.reference_id, + }) + + const rateWithRulesAfterReAdd = await service.retrieve(rate.id, { + relations: ["rules"], + }) + expect(rateWithRulesAfterReAdd.rules.length).toBe(2) + }) + + it("should fail on duplicate rules", async () => { + const region = await service.createTaxRegions({ + country_code: "US", + }) + + await expect( + service.create({ + tax_region_id: region.id, + value: 10, + code: "test", + name: "test", + rules: [ + { reference: "product", reference_id: "product_id_1" }, + { reference: "product", reference_id: "product_id_1" }, + ], + }) + ).rejects.toThrowError() + }) + it("should fail to create province region belonging to a parent with non-matching country", async () => { const caRegion = await service.createTaxRegions({ country_code: "CA", @@ -391,6 +583,52 @@ moduleIntegrationTestRunner({ }) ).rejects.toThrowError() }) + + it("should delete all child regions when parent region is deleted", async () => { + const region = await service.createTaxRegions({ + country_code: "CA", + }) + const provinceRegion = await service.createTaxRegions({ + parent_id: region.id, + country_code: "CA", + province_code: "QC", + }) + + await service.deleteTaxRegions(region.id) + + const taxRegions = await service.listTaxRegions({ + id: provinceRegion.id, + }) + + expect(taxRegions).toEqual([]) + }) + + it("it should soft delete all child regions when parent region is deleted", async () => { + const region = await service.createTaxRegions({ + country_code: "CA", + }) + const provinceRegion = await service.createTaxRegions({ + parent_id: region.id, + country_code: "CA", + province_code: "QC", + }) + + await service.softDeleteTaxRegions([region.id]) + + const taxRegions = await service.listTaxRegions( + { + id: provinceRegion.id, + }, + { withDeleted: true } + ) + + expect(taxRegions).toEqual([ + expect.objectContaining({ + id: provinceRegion.id, + deleted_at: expect.any(Date), + }), + ]) + }) }) }, }) diff --git a/packages/tax/jest.config.js b/packages/tax/jest.config.js index 58c887c1c3..0c652264ea 100644 --- a/packages/tax/jest.config.js +++ b/packages/tax/jest.config.js @@ -9,7 +9,7 @@ module.exports = { "^.+\\.[jt]s?$": [ "ts-jest", { - tsConfig: "tsconfig.spec.json", + tsconfig: "tsconfig.spec.json", isolatedModules: true, }, ], diff --git a/packages/tax/src/models/tax-rate-rule.ts b/packages/tax/src/models/tax-rate-rule.ts index 0069548e73..3fbdbaa62e 100644 --- a/packages/tax/src/models/tax-rate-rule.ts +++ b/packages/tax/src/models/tax-rate-rule.ts @@ -1,35 +1,77 @@ +import { DAL } from "@medusajs/types" +import { + DALUtils, + createPsqlIndexStatementHelper, + generateEntityId, +} from "@medusajs/utils" import { Cascade, Entity, ManyToOne, PrimaryKey, - PrimaryKeyProp, Property, + Filter, + OptionalProps, + BeforeCreate, + OnInit, } from "@mikro-orm/core" import TaxRate from "./tax-rate" const TABLE_NAME = "tax_rate_rule" +type OptionalRuleProps = DAL.SoftDeletableEntityDateColumns + const taxRateIdIndexName = "IDX_tax_rate_rule_tax_rate_id" +const taxRateIdIndexStatement = createPsqlIndexStatementHelper({ + name: taxRateIdIndexName, + tableName: TABLE_NAME, + columns: "tax_rate_id", + where: "deleted_at IS NULL", +}) + +const referenceIdIndexName = "IDX_tax_rate_rule_reference_id" +const referenceIdIndexStatement = createPsqlIndexStatementHelper({ + name: referenceIdIndexName, + tableName: TABLE_NAME, + columns: "reference_id", + where: "deleted_at IS NULL", +}) + +const uniqueRateReferenceIndexName = "IDX_tax_rate_rule_unique_rate_reference" +const uniqueRateReferenceIndexStatement = createPsqlIndexStatementHelper({ + name: uniqueRateReferenceIndexName, + tableName: TABLE_NAME, + columns: ["tax_rate_id", "reference_id"], + unique: true, + where: "deleted_at IS NULL", +}) @Entity({ tableName: TABLE_NAME }) +@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions) +@uniqueRateReferenceIndexStatement.MikroORMIndex() export default class TaxRateRule { - @PrimaryKey({ columnType: "text" }) - tax_rate_id!: string + [OptionalProps]?: OptionalRuleProps @PrimaryKey({ columnType: "text" }) - reference_id!: string; + id!: string - [PrimaryKeyProp]?: ["tax_rate_id", "reference_id"] + @ManyToOne(() => TaxRate, { + type: "text", + fieldName: "tax_rate_id", + mapToPk: true, + cascade: [Cascade.REMOVE], + }) + @taxRateIdIndexStatement.MikroORMIndex() + tax_rate_id: string + + @Property({ columnType: "text" }) + @referenceIdIndexStatement.MikroORMIndex() + reference_id: string @Property({ columnType: "text" }) reference: string - @ManyToOne(() => TaxRate, { - fieldName: "tax_rate_id", - index: taxRateIdIndexName, - cascade: [Cascade.REMOVE, Cascade.PERSIST], - }) + @ManyToOne(() => TaxRate, { persist: false }) tax_rate: TaxRate @Property({ columnType: "jsonb", nullable: true }) @@ -52,4 +94,22 @@ export default class TaxRateRule { @Property({ columnType: "text", nullable: true }) created_by: string | null = null + + @createPsqlIndexStatementHelper({ + tableName: TABLE_NAME, + columns: "deleted_at", + where: "deleted_at IS NOT NULL", + }).MikroORMIndex() + @Property({ columnType: "timestamptz", nullable: true }) + deleted_at: Date | null = null + + @BeforeCreate() + onCreate() { + this.id = generateEntityId(this.id, "txr") + } + + @OnInit() + onInit() { + this.id = generateEntityId(this.id, "txr") + } } diff --git a/packages/tax/src/models/tax-rate.ts b/packages/tax/src/models/tax-rate.ts index 2ab8d22964..14e66148c2 100644 --- a/packages/tax/src/models/tax-rate.ts +++ b/packages/tax/src/models/tax-rate.ts @@ -1,9 +1,11 @@ import { DAL } from "@medusajs/types" import { + DALUtils, createPsqlIndexStatementHelper, generateEntityId, } from "@medusajs/utils" import { + Filter, BeforeCreate, Cascade, Collection, @@ -18,25 +20,32 @@ import { import TaxRegion from "./tax-region" import TaxRateRule from "./tax-rate-rule" -type OptionalTaxRateProps = DAL.EntityDateColumns +type OptionalTaxRateProps = DAL.SoftDeletableEntityDateColumns 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", + where: "is_default = true AND deleted_at IS NULL", +}) + +const taxRegionIdIndexName = "IDX_tax_rate_tax_region_id" +const taxRegionIdIndexStatement = createPsqlIndexStatementHelper({ + name: taxRegionIdIndexName, + tableName: TABLE_NAME, + columns: "tax_region_id", + where: "deleted_at IS NULL", }) @singleDefaultRegionIndexStatement.MikroORMIndex() @Entity({ tableName: TABLE_NAME }) +@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions) export default class TaxRate { - [OptionalProps]: OptionalTaxRateProps + [OptionalProps]?: OptionalTaxRateProps @PrimaryKey({ columnType: "text" }) id!: string @@ -56,17 +65,21 @@ export default class TaxRate { @Property({ columnType: "bool", default: false }) is_combinable = false - @Property({ columnType: "text" }) + @ManyToOne(() => TaxRegion, { + type: "text", + fieldName: "tax_region_id", + mapToPk: true, + cascade: [Cascade.REMOVE], + }) + @taxRegionIdIndexStatement.MikroORMIndex() tax_region_id: string - @ManyToOne(() => TaxRegion, { - fieldName: "tax_region_id", - index: taxRegionIdIndexName, - cascade: [Cascade.REMOVE, Cascade.PERSIST], - }) + @ManyToOne({ entity: () => TaxRegion, persist: false }) tax_region: TaxRegion - @OneToMany(() => TaxRateRule, (rule) => rule.tax_rate) + @OneToMany(() => TaxRateRule, (rule) => rule.tax_rate, { + cascade: ["soft-remove" as Cascade], + }) rules = new Collection(this) @Property({ columnType: "jsonb", nullable: true }) @@ -90,6 +103,14 @@ export default class TaxRate { @Property({ columnType: "text", nullable: true }) created_by: string | null = null + @createPsqlIndexStatementHelper({ + tableName: TABLE_NAME, + columns: "deleted_at", + where: "deleted_at IS NOT NULL", + }).MikroORMIndex() + @Property({ columnType: "timestamptz", nullable: true }) + deleted_at: Date | null = null + @BeforeCreate() onCreate() { this.id = generateEntityId(this.id, "txr") diff --git a/packages/tax/src/models/tax-region.ts b/packages/tax/src/models/tax-region.ts index 5a436ddedf..5ed4ec00e4 100644 --- a/packages/tax/src/models/tax-region.ts +++ b/packages/tax/src/models/tax-region.ts @@ -1,9 +1,11 @@ import { DAL } from "@medusajs/types" import { + DALUtils, createPsqlIndexStatementHelper, generateEntityId, } from "@medusajs/utils" import { + Filter, BeforeCreate, Collection, Entity, @@ -18,7 +20,7 @@ import { } from "@mikro-orm/core" import TaxRate from "./tax-rate" -type OptionalTaxRegionProps = DAL.EntityDateColumns +type OptionalTaxRegionProps = DAL.SoftDeletableEntityDateColumns const TABLE_NAME = "tax_region" @@ -37,8 +39,9 @@ const taxRegionCountryTopLevelCheckName = "CK_tax_region_country_top_level" }) @countryCodeProvinceIndexStatement.MikroORMIndex() @Entity({ tableName: TABLE_NAME }) +@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions) export default class TaxRegion { - [OptionalProps]: OptionalTaxRegionProps + [OptionalProps]?: OptionalTaxRegionProps @PrimaryKey({ columnType: "text" }) id!: string @@ -49,20 +52,28 @@ export default class TaxRegion { @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", + fieldName: "parent_id", + cascade: [Cascade.REMOVE], + mapToPk: true, nullable: true, }) + parent_id: string | null = null + + @ManyToOne(() => TaxRegion, { persist: false }) parent: TaxRegion - @OneToMany(() => TaxRate, (label) => label.tax_region) + @OneToMany(() => TaxRate, (label) => label.tax_region, { + cascade: ["soft-remove" as Cascade], + }) tax_rates = new Collection(this) + @OneToMany(() => TaxRegion, (label) => label.parent, { + cascade: ["soft-remove" as Cascade], + }) + children = new Collection(this) + @Property({ columnType: "jsonb", nullable: true }) metadata: Record | null = null @@ -84,6 +95,14 @@ export default class TaxRegion { @Property({ columnType: "text", nullable: true }) created_by: string | null = null + @createPsqlIndexStatementHelper({ + tableName: TABLE_NAME, + columns: "deleted_at", + where: "deleted_at IS NOT NULL", + }).MikroORMIndex() + @Property({ columnType: "timestamptz", nullable: true }) + deleted_at: Date | null = null + @BeforeCreate() onCreate() { this.id = generateEntityId(this.id, "txreg") diff --git a/packages/types/src/tax/service.ts b/packages/types/src/tax/service.ts index f48fb09e70..6567bef0a7 100644 --- a/packages/types/src/tax/service.ts +++ b/packages/types/src/tax/service.ts @@ -99,4 +99,22 @@ export interface ITaxModuleService extends IModuleService { calculationContext: TaxCalculationContext, sharedContext?: Context ): Promise<(ItemTaxLineDTO | ShippingTaxLineDTO)[]> + + softDelete( + taxRateIds: string[], + config?: SoftDeleteReturn, + sharedContext?: Context + ): Promise | void> + + softDeleteTaxRegions( + taxRegionIds: string[], + config?: SoftDeleteReturn, + sharedContext?: Context + ): Promise | void> + + softDeleteTaxRateRules( + taxRateRulePairs: { tax_rate_id: string; reference_id: string }[], + config?: SoftDeleteReturn, + sharedContext?: Context + ): Promise | void> }