From 555eb41fcad03d069ffd4580116821a7cb3c2606 Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Mon, 4 Mar 2024 11:30:54 +0100 Subject: [PATCH] feat(tax): add endpoints to manage tax rate rules (#6557) **What** Adds endpoints to manage tax rules on a tax rate: - Create a tax rule: POST /admin/tax-rates/:id/rules - Delete a tax rule: DELETE /admin/tax-rates/:id/rules/:rule_id - Replace tax rules: POST /admin/tax-rates/:id -- with { rules: [...] } in body. ### Noteworthy things I bumped into **Updating nested relationships** A TaxRate can have multiple TaxRules and in this PR we enable users to replace all TaxRules associated with a TaxRate in one operation. If working with the module directly this can be done with: ```javascript taxModuleService.update(rateId, { rules: [{ ... }] }) ``` Internally in the `update` function the TaxModule first soft deletes any TaxRules that exist on the TaxRate and then creates new TaxRules for the passed rules ([see test](https://github.com/medusajs/medusa/pull/6557/files#diff-cdcbab80ac7928b80648088ec57a3ab09dddd4409d6afce034f2caff08ee022bR78)). A challenge arises when doing this in a compensatable way in a workflow. To see this imagine the following: 1. `updateTaxRatesWorkflow` gets the current data for the tax rates to update. This includes the tax rates' rules. 2. `updateTaxRatesWorkflow` calls `taxModuleService.update` with new rules. 3. Internally, the tax module deletes the rules in 1. and creates new rules. 4. Imagine an error happens in a following step and the workflow has to compensate. 5. The workflow uses the data from 1. and calls upsert. The tax module may correctly update the previous tax rules so they are no longer soft deleted. However, upsert (at least not by default) doesn't delete the new rules that were created in 2. As illustrated by 5. compensating the update is not pretty. To get around this I instead opted to let the workflow handle setting the rules for a rate that makes the compensation more straightforward to handle. [See workflow here](https://github.com/medusajs/medusa/pull/6557/files#diff-ff19e1f2fa32289aefff90d33c05c154f9605a3c5da6a62683071a1fcaedfd7bR89). **Using nested workflows** Initially, I wanted to use the `setTaxRateRulesWorkflow` within the `updateTaxRatesWorkflow`. And this worked great for the invoke phase. However, when I needed to compensate the update workflow (and hence also had to compensate the set rules workflow), I found that the workflow engine no longer had the set rules transaction in memory and therefore could not roll it back. ([This is where I try to rollback](https://github.com/medusajs/medusa/pull/6557/files#diff-ff19e1f2fa32289aefff90d33c05c154f9605a3c5da6a62683071a1fcaedfd7bR62), but the transaction id can't be found). I therefore opted to copy the steps from the set tax rate rules workflow into the update tax rates workflow; however, once we figure out a good way to ensure we can compensate nested workflows we should move to the nested workflow instead. This also made me realize that the current implementation of workflows that use `refreshCartPromotions` may create inconsistencies in case of failures (cc: @riqwan). --- .../modules/__tests__/tax/admin/tax.spec.ts | 135 +++++++++++ .../__tests__/tax/workflow/tax.spec.ts | 209 ++++++++++++++++++ .../src/tax/steps/create-tax-rate-rules.ts | 31 +++ .../src/tax/steps/delete-tax-rate-rules.ts | 32 +++ packages/core-flows/src/tax/steps/index.ts | 4 + .../src/tax/steps/list-tax-rate-ids.ts | 23 ++ .../src/tax/steps/list-tax-rate-rule-ids.ts | 22 ++ .../tax/workflows/create-tax-rate-rules.ts | 15 ++ .../tax/workflows/delete-tax-rate-rules.ts | 12 + .../core-flows/src/tax/workflows/index.ts | 3 + .../src/tax/workflows/set-tax-rate-rules.ts | 46 ++++ .../src/tax/workflows/update-tax-rates.ts | 134 ++++++++++- .../src/api-v2/admin/tax-rates/[id]/route.ts | 2 +- .../tax-rates/[id]/rules/[rule_id]/route.ts | 33 +++ .../admin/tax-rates/[id]/rules/route.ts | 42 ++++ .../src/api-v2/admin/tax-rates/middlewares.ts | 6 + .../src/api-v2/admin/tax-rates/validators.ts | 6 + packages/medusa/src/loaders/api.ts | 4 +- .../integration-tests/__tests__/index.spec.ts | 83 +++++++ packages/tax/src/models/tax-rate-rule.ts | 4 +- packages/tax/src/models/tax-rate.ts | 1 + .../tax/src/services/tax-module-service.ts | 78 +++++++ packages/types/src/tax/common.ts | 3 +- packages/types/src/tax/mutations.ts | 6 +- packages/types/src/tax/service.ts | 12 +- 25 files changed, 933 insertions(+), 13 deletions(-) create mode 100644 integration-tests/modules/__tests__/tax/workflow/tax.spec.ts create mode 100644 packages/core-flows/src/tax/steps/create-tax-rate-rules.ts create mode 100644 packages/core-flows/src/tax/steps/delete-tax-rate-rules.ts create mode 100644 packages/core-flows/src/tax/steps/list-tax-rate-ids.ts create mode 100644 packages/core-flows/src/tax/steps/list-tax-rate-rule-ids.ts create mode 100644 packages/core-flows/src/tax/workflows/create-tax-rate-rules.ts create mode 100644 packages/core-flows/src/tax/workflows/delete-tax-rate-rules.ts create mode 100644 packages/core-flows/src/tax/workflows/set-tax-rate-rules.ts create mode 100644 packages/medusa/src/api-v2/admin/tax-rates/[id]/rules/[rule_id]/route.ts create mode 100644 packages/medusa/src/api-v2/admin/tax-rates/[id]/rules/route.ts diff --git a/integration-tests/modules/__tests__/tax/admin/tax.spec.ts b/integration-tests/modules/__tests__/tax/admin/tax.spec.ts index 23c675cbadda5..9a4e7efce3979 100644 --- a/integration-tests/modules/__tests__/tax/admin/tax.spec.ts +++ b/integration-tests/modules/__tests__/tax/admin/tax.spec.ts @@ -373,4 +373,139 @@ describe("Taxes - Admin", () => { expect(rates.length).toEqual(1) expect(rates[0].deleted_at).not.toBeNull() }) + + it("can create a tax rate add rules and remove them", async () => { + const api = useApi() as any + const regionRes = await api.post( + `/admin/tax-regions`, + { + country_code: "us", + default_tax_rate: { code: "default", rate: 2, name: "default rate" }, + }, + adminHeaders + ) + + const usRegionId = regionRes.data.tax_region.id + const rateRes = await api.post( + `/admin/tax-rates`, + { + tax_region_id: usRegionId, + code: "RATE2", + name: "another rate", + rate: 10, + rules: [{ reference: "product", reference_id: "prod_1234" }], + }, + adminHeaders + ) + const rateId = rateRes.data.tax_rate.id + let rules = await service.listTaxRateRules({ tax_rate_id: rateId }) + + expect(rules).toEqual([ + { + id: expect.any(String), + tax_rate_id: rateId, + reference: "product", + reference_id: "prod_1234", + created_by: "admin_user", + created_at: expect.any(Date), + updated_at: expect.any(Date), + deleted_at: null, + tax_rate: { id: rateId }, + metadata: null, + }, + ]) + + await api.post( + `/admin/tax-rates/${rateId}/rules`, + { + reference: "product", + reference_id: "prod_1111", + }, + adminHeaders + ) + + await api.post( + `/admin/tax-rates/${rateId}/rules`, + { + reference: "product", + reference_id: "prod_2222", + }, + adminHeaders + ) + rules = await service.listTaxRateRules({ tax_rate_id: rateId }) + expect(rules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + tax_rate_id: rateId, + reference: "product", + reference_id: "prod_1234", + created_by: "admin_user", + }), + expect.objectContaining({ + tax_rate_id: rateId, + reference: "product", + reference_id: "prod_1111", + created_by: "admin_user", + }), + expect.objectContaining({ + tax_rate_id: rateId, + reference: "product", + reference_id: "prod_2222", + created_by: "admin_user", + }), + ]) + ) + + const toDeleteId = rules.find((r) => r.reference_id === "prod_1111")!.id + await api.delete( + `/admin/tax-rates/${rateId}/rules/${toDeleteId}`, + adminHeaders + ) + + rules = await service.listTaxRateRules({ tax_rate_id: rateId }) + expect(rules.length).toEqual(2) + + await api.post( + `/admin/tax-rates/${rateId}`, + { + rules: [ + { reference: "product", reference_id: "prod_3333" }, + { reference: "product", reference_id: "prod_4444" }, + { reference: "product", reference_id: "prod_5555" }, + { reference: "product", reference_id: "prod_6666" }, + ], + }, + adminHeaders + ) + rules = await service.listTaxRateRules({ tax_rate_id: rateId }) + expect(rules.length).toEqual(4) + expect(rules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + tax_rate_id: rateId, + reference: "product", + reference_id: "prod_3333", + created_by: "admin_user", + }), + expect.objectContaining({ + tax_rate_id: rateId, + reference: "product", + reference_id: "prod_4444", + created_by: "admin_user", + }), + expect.objectContaining({ + tax_rate_id: rateId, + reference: "product", + reference_id: "prod_5555", + created_by: "admin_user", + }), + expect.objectContaining({ + tax_rate_id: rateId, + reference: "product", + reference_id: "prod_6666", + created_by: "admin_user", + }), + ]) + ) + }) }) diff --git a/integration-tests/modules/__tests__/tax/workflow/tax.spec.ts b/integration-tests/modules/__tests__/tax/workflow/tax.spec.ts new file mode 100644 index 0000000000000..ea6a57e0d1598 --- /dev/null +++ b/integration-tests/modules/__tests__/tax/workflow/tax.spec.ts @@ -0,0 +1,209 @@ +import path from "path" +import { ITaxModuleService } from "@medusajs/types" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +import { createAdminUser } from "../../../helpers/create-admin-user" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import { getContainer } from "../../../../environment-helpers/use-container" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { + createTaxRateRulesStepId, + maybeSetTaxRateRulesStepId, + updateTaxRatesStepId, + updateTaxRatesWorkflow, +} from "@medusajs/core-flows" + +jest.setTimeout(50000) + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("Taxes - Workflow", () => { + let dbConnection + let appContainer + let shutdownServer + let service: ITaxModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + service = appContainer.resolve(ModuleRegistrationName.TAX) + }) + + beforeEach(async () => { + await createAdminUser(dbConnection, adminHeaders) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("compensates rules correctly", async () => { + const taxRegion = await service.createTaxRegions({ + country_code: "us", + }) + + const [rateOne, rateTwo] = await service.create([ + { + tax_region_id: taxRegion.id, + rate: 10, + code: "standard", + name: "Standard", + rules: [ + { reference: "shipping", reference_id: "shipping_12354" }, + { reference: "shipping", reference_id: "shipping_11111" }, + { reference: "shipping", reference_id: "shipping_22222" }, + ], + }, + { + tax_region_id: taxRegion.id, + rate: 2, + code: "reduced", + name: "Reduced", + rules: [ + { reference: "product", reference_id: "product_12354" }, + { reference: "product", reference_id: "product_11111" }, + { reference: "product", reference_id: "product_22222" }, + ], + }, + ]) + + const workflow = updateTaxRatesWorkflow(appContainer) + + workflow.appendAction("throw", createTaxRateRulesStepId, { + invoke: async function failStep() { + throw new Error(`Failed to update`) + }, + }) + + await workflow.run({ + input: { + selector: { tax_region_id: taxRegion.id }, + update: { + rate: 2, + rules: [ + { reference: "product", reference_id: "product_12354" }, + { reference: "shipping", reference_id: "shipping_12354" }, + ], + }, + }, + throwOnError: false, + }) + + const taxRateRules = await service.listTaxRateRules({ + tax_rate: { tax_region_id: taxRegion.id }, + }) + + expect(taxRateRules.length).toEqual(6) + expect(taxRateRules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + tax_rate_id: rateOne.id, + reference_id: "shipping_12354", + }), + expect.objectContaining({ + tax_rate_id: rateOne.id, + reference_id: "shipping_11111", + }), + expect.objectContaining({ + tax_rate_id: rateOne.id, + reference_id: "shipping_22222", + }), + expect.objectContaining({ + tax_rate_id: rateTwo.id, + reference_id: "product_12354", + }), + expect.objectContaining({ + tax_rate_id: rateTwo.id, + reference_id: "product_11111", + }), + expect.objectContaining({ + tax_rate_id: rateTwo.id, + reference_id: "product_22222", + }), + ]) + ) + }) + + it("creates rules correctly", async () => { + const taxRegion = await service.createTaxRegions({ + country_code: "us", + }) + + const [rateOne, rateTwo] = await service.create([ + { + tax_region_id: taxRegion.id, + rate: 10, + code: "standard", + name: "Standard", + rules: [ + { reference: "shipping", reference_id: "shipping_12354" }, + { reference: "shipping", reference_id: "shipping_11111" }, + { reference: "shipping", reference_id: "shipping_22222" }, + ], + }, + { + tax_region_id: taxRegion.id, + rate: 2, + code: "reduced", + name: "Reduced", + rules: [ + { reference: "product", reference_id: "product_12354" }, + { reference: "product", reference_id: "product_11111" }, + { reference: "product", reference_id: "product_22222" }, + ], + }, + ]) + + await updateTaxRatesWorkflow(appContainer).run({ + input: { + selector: { tax_region_id: taxRegion.id }, + update: { + rate: 2, + rules: [ + { reference: "product", reference_id: "product_12354" }, + { reference: "shipping", reference_id: "shipping_12354" }, + ], + }, + }, + }) + + const taxRateRules = await service.listTaxRateRules({ + tax_rate: { tax_region_id: taxRegion.id }, + }) + + expect(taxRateRules.length).toEqual(4) + expect(taxRateRules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + tax_rate_id: rateOne.id, + reference_id: "shipping_12354", + }), + expect.objectContaining({ + tax_rate_id: rateTwo.id, + reference_id: "shipping_12354", + }), + expect.objectContaining({ + tax_rate_id: rateOne.id, + reference_id: "product_12354", + }), + expect.objectContaining({ + tax_rate_id: rateTwo.id, + reference_id: "product_12354", + }), + ]) + ) + }) +}) diff --git a/packages/core-flows/src/tax/steps/create-tax-rate-rules.ts b/packages/core-flows/src/tax/steps/create-tax-rate-rules.ts new file mode 100644 index 0000000000000..f73e3477708e6 --- /dev/null +++ b/packages/core-flows/src/tax/steps/create-tax-rate-rules.ts @@ -0,0 +1,31 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { CreateTaxRateRuleDTO, ITaxModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const createTaxRateRulesStepId = "create-tax-rate-rules" +export const createTaxRateRulesStep = createStep( + createTaxRateRulesStepId, + async (data: CreateTaxRateRuleDTO[], { container }) => { + const service = container.resolve( + ModuleRegistrationName.TAX + ) + + const created = await service.createTaxRateRules(data) + + return new StepResponse( + created, + created.map((rule) => rule.id) + ) + }, + async (createdIds, { container }) => { + if (!createdIds?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.TAX + ) + + await service.deleteTaxRateRules(createdIds) + } +) diff --git a/packages/core-flows/src/tax/steps/delete-tax-rate-rules.ts b/packages/core-flows/src/tax/steps/delete-tax-rate-rules.ts new file mode 100644 index 0000000000000..9003b4bdf7b9e --- /dev/null +++ b/packages/core-flows/src/tax/steps/delete-tax-rate-rules.ts @@ -0,0 +1,32 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ITaxModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const deleteTaxRateRulesStepId = "delete-tax-rate-rules" +export const deleteTaxRateRulesStep = createStep( + deleteTaxRateRulesStepId, + async (ids: string[], { container }) => { + if (!ids?.length) { + return new StepResponse(void 0, []) + } + + const service = container.resolve( + ModuleRegistrationName.TAX + ) + + await service.softDeleteTaxRateRules(ids) + + return new StepResponse(void 0, ids) + }, + async (prevIds, { container }) => { + if (!prevIds?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.TAX + ) + + await service.restoreTaxRateRules(prevIds) + } +) diff --git a/packages/core-flows/src/tax/steps/index.ts b/packages/core-flows/src/tax/steps/index.ts index 5bb861dab387d..12daa23ac3da1 100644 --- a/packages/core-flows/src/tax/steps/index.ts +++ b/packages/core-flows/src/tax/steps/index.ts @@ -3,3 +3,7 @@ export * from "./delete-tax-regions" export * from "./create-tax-rates" export * from "./update-tax-rates" export * from "./delete-tax-rates" +export * from "./delete-tax-rate-rules" +export * from "./create-tax-rate-rules" +export * from "./list-tax-rate-rule-ids" +export * from "./list-tax-rate-ids" diff --git a/packages/core-flows/src/tax/steps/list-tax-rate-ids.ts b/packages/core-flows/src/tax/steps/list-tax-rate-ids.ts new file mode 100644 index 0000000000000..30da06c2372b8 --- /dev/null +++ b/packages/core-flows/src/tax/steps/list-tax-rate-ids.ts @@ -0,0 +1,23 @@ +import { createStep, StepResponse } from "@medusajs/workflows-sdk" +import { FilterableTaxRateProps, ITaxModuleService } from "@medusajs/types" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +type StepInput = { + selector: FilterableTaxRateProps +} + +export const listTaxRateIdsStepId = "list-tax-rate-ids" +export const listTaxRateIdsStep = createStep( + listTaxRateIdsStepId, + async (input: StepInput, { container }) => { + const service = container.resolve( + ModuleRegistrationName.TAX + ) + + const rates = await service.list(input.selector, { + select: ["id"], + }) + + return new StepResponse(rates.map((r) => r.id)) + } +) diff --git a/packages/core-flows/src/tax/steps/list-tax-rate-rule-ids.ts b/packages/core-flows/src/tax/steps/list-tax-rate-rule-ids.ts new file mode 100644 index 0000000000000..8f1fa65c2b0fd --- /dev/null +++ b/packages/core-flows/src/tax/steps/list-tax-rate-rule-ids.ts @@ -0,0 +1,22 @@ +import { createStep, StepResponse } from "@medusajs/workflows-sdk" +import { FilterableTaxRateRuleProps, ITaxModuleService } from "@medusajs/types" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +type StepInput = { + selector: FilterableTaxRateRuleProps +} + +export const listTaxRateRuleIdsStepId = "list-tax-rate-rule-ids" +export const listTaxRateRuleIdsStep = createStep( + listTaxRateRuleIdsStepId, + async (input: StepInput, { container }) => { + const service = container.resolve( + ModuleRegistrationName.TAX + ) + + const rules = await service.listTaxRateRules(input.selector, { + select: ["id"], + }) + return new StepResponse(rules.map((r) => r.id)) + } +) diff --git a/packages/core-flows/src/tax/workflows/create-tax-rate-rules.ts b/packages/core-flows/src/tax/workflows/create-tax-rate-rules.ts new file mode 100644 index 0000000000000..f6f5bc1f57b1c --- /dev/null +++ b/packages/core-flows/src/tax/workflows/create-tax-rate-rules.ts @@ -0,0 +1,15 @@ +import { CreateTaxRateRuleDTO, TaxRateRuleDTO } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { createTaxRateRulesStep } from "../steps" + +type WorkflowInput = { + rules: CreateTaxRateRuleDTO[] +} + +export const createTaxRateRulesWorkflowId = "create-tax-rate-rules" +export const createTaxRateRulesWorkflow = createWorkflow( + createTaxRateRulesWorkflowId, + (input: WorkflowData): WorkflowData => { + return createTaxRateRulesStep(input.rules) + } +) diff --git a/packages/core-flows/src/tax/workflows/delete-tax-rate-rules.ts b/packages/core-flows/src/tax/workflows/delete-tax-rate-rules.ts new file mode 100644 index 0000000000000..64a1a5f58cf43 --- /dev/null +++ b/packages/core-flows/src/tax/workflows/delete-tax-rate-rules.ts @@ -0,0 +1,12 @@ +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { deleteTaxRateRulesStep } from "../steps" + +type WorkflowInput = { ids: string[] } + +export const deleteTaxRateRulesWorkflowId = "delete-tax-rate-rules" +export const deleteTaxRateRulesWorkflow = createWorkflow( + deleteTaxRateRulesWorkflowId, + (input: WorkflowData): WorkflowData => { + return deleteTaxRateRulesStep(input.ids) + } +) diff --git a/packages/core-flows/src/tax/workflows/index.ts b/packages/core-flows/src/tax/workflows/index.ts index 5bb861dab387d..962cb3296103f 100644 --- a/packages/core-flows/src/tax/workflows/index.ts +++ b/packages/core-flows/src/tax/workflows/index.ts @@ -3,3 +3,6 @@ export * from "./delete-tax-regions" export * from "./create-tax-rates" export * from "./update-tax-rates" export * from "./delete-tax-rates" +export * from "./set-tax-rate-rules" +export * from "./create-tax-rate-rules" +export * from "./delete-tax-rate-rules" diff --git a/packages/core-flows/src/tax/workflows/set-tax-rate-rules.ts b/packages/core-flows/src/tax/workflows/set-tax-rate-rules.ts new file mode 100644 index 0000000000000..d31e6da345550 --- /dev/null +++ b/packages/core-flows/src/tax/workflows/set-tax-rate-rules.ts @@ -0,0 +1,46 @@ +import { CreateTaxRateRuleDTO, TaxRateRuleDTO } from "@medusajs/types" +import { + WorkflowData, + createWorkflow, + transform, +} from "@medusajs/workflows-sdk" +import { + createTaxRateRulesStep, + deleteTaxRateRulesStep, + listTaxRateRuleIdsStep, +} from "../steps" + +type WorkflowInput = { + tax_rate_ids: string[] + rules: Omit[] +} + +export const setTaxRateRulesWorkflowId = "set-tax-rate-rules" +export const setTaxRateRulesWorkflow = createWorkflow( + setTaxRateRulesWorkflowId, + (input: WorkflowData): WorkflowData => { + const ruleIds = listTaxRateRuleIdsStep({ + selector: { tax_rate_id: input.tax_rate_ids }, + }) + + deleteTaxRateRulesStep(ruleIds) + + const rulesWithRateId = transform( + { rules: input.rules, rateIds: input.tax_rate_ids }, + ({ rules, rateIds }) => { + return rules + .map((r) => { + return rateIds.map((id) => { + return { + ...r, + tax_rate_id: id, + } + }) + }) + .flat() + } + ) + + return createTaxRateRulesStep(rulesWithRateId) + } +) diff --git a/packages/core-flows/src/tax/workflows/update-tax-rates.ts b/packages/core-flows/src/tax/workflows/update-tax-rates.ts index 16a10b59f3dee..fe5c8ae670f69 100644 --- a/packages/core-flows/src/tax/workflows/update-tax-rates.ts +++ b/packages/core-flows/src/tax/workflows/update-tax-rates.ts @@ -1,10 +1,23 @@ import { FilterableTaxRateProps, + ITaxModuleService, TaxRateDTO, UpdateTaxRateDTO, } from "@medusajs/types" -import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" -import { updateTaxRatesStep } from "../steps" +import { + StepResponse, + WorkflowData, + createStep, + createWorkflow, + transform, +} from "@medusajs/workflows-sdk" +import { + createTaxRateRulesStep, + deleteTaxRateRulesStep, + updateTaxRatesStep, +} from "../steps" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +// import { setTaxRateRulesWorkflow } from "./set-tax-rate-rules" type UpdateTaxRatesStepInput = { selector: FilterableTaxRateProps @@ -13,10 +26,125 @@ type UpdateTaxRatesStepInput = { type WorkflowInput = UpdateTaxRatesStepInput +type StepInput = { + tax_rate_ids: string[] + update: UpdateTaxRateDTO +} + +// TODO: When we figure out how to compensate nested workflows, we can use this +// +// export const maybeSetTaxRateRulesStepId = "maybe-set-tax-rate-rules" +// const maybeSetTaxRateRules = createStep( +// maybeSetTaxRateRulesStepId, +// async (input: StepInput, { container }) => { +// const { update } = input +// +// if (!update.rules) { +// return new StepResponse([], "") +// } +// +// const { result, transaction } = await setTaxRateRulesWorkflow( +// container +// ).run({ +// input: { +// tax_rate_ids: input.tax_rate_ids, +// rules: update.rules, +// }, +// }) +// +// return new StepResponse(result, transaction.transactionId) +// }, +// async (transactionId, { container }) => { +// if (!transactionId) { +// return +// } +// +// await setTaxRateRulesWorkflow(container).cancel(transactionId) +// } +// ) + +const maybeListTaxRateRuleIdsStepId = "maybe-list-tax-rate-rule-ids" +const maybeListTaxRateRuleIdsStep = createStep( + maybeListTaxRateRuleIdsStepId, + async (input: StepInput, { container }) => { + const { update, tax_rate_ids } = input + + if (!update.rules) { + return new StepResponse([]) + } + + const service = container.resolve( + ModuleRegistrationName.TAX + ) + + const rules = await service.listTaxRateRules( + { tax_rate_id: tax_rate_ids }, + { select: ["id"] } + ) + + return new StepResponse(rules.map((r) => r.id)) + } +) + export const updateTaxRatesWorkflowId = "update-tax-rates" export const updateTaxRatesWorkflow = createWorkflow( updateTaxRatesWorkflowId, (input: WorkflowData): WorkflowData => { - return updateTaxRatesStep(input) + const cleanedUpdateInput = transform(input, (data) => { + // Transform clones data so we can safely modify it + if (data.update.rules) { + delete data.update.rules + } + + return { + selector: data.selector, + update: data.update, + } + }) + + const updatedRates = updateTaxRatesStep(cleanedUpdateInput) + const rateIds = transform(updatedRates, (rates) => rates.map((r) => r.id)) + + // TODO: Use when we figure out how to compensate nested workflows + // maybeSetTaxRateRules({ + // tax_rate_ids: rateIds, + // update: input.update, + // }) + + // COPY-PASTE from set-tax-rate-rules.ts + const ruleIds = maybeListTaxRateRuleIdsStep({ + tax_rate_ids: rateIds, + update: input.update, + }) + + deleteTaxRateRulesStep(ruleIds) + + const rulesWithRateId = transform( + { update: input.update, rateIds }, + ({ update, rateIds }) => { + if (!update.rules) { + return [] + } + + const updatedBy = update.updated_by + + return update.rules + .map((r) => { + return rateIds.map((id) => { + return { + ...r, + created_by: updatedBy, + tax_rate_id: id, + } + }) + }) + .flat() + } + ) + + createTaxRateRulesStep(rulesWithRateId) + // end of COPY-PASTE from set-tax-rate-rules.ts + + return updatedRates } ) diff --git a/packages/medusa/src/api-v2/admin/tax-rates/[id]/route.ts b/packages/medusa/src/api-v2/admin/tax-rates/[id]/route.ts index 6f8fdf6b3151a..51aa8f0bafaf0 100644 --- a/packages/medusa/src/api-v2/admin/tax-rates/[id]/route.ts +++ b/packages/medusa/src/api-v2/admin/tax-rates/[id]/route.ts @@ -18,7 +18,7 @@ export const POST = async ( const { errors } = await updateTaxRatesWorkflow(req.scope).run({ input: { selector: { id: req.params.id }, - update: req.validatedBody, + update: { ...req.validatedBody, updated_by: req.auth.actor_id }, }, throwOnError: false, }) diff --git a/packages/medusa/src/api-v2/admin/tax-rates/[id]/rules/[rule_id]/route.ts b/packages/medusa/src/api-v2/admin/tax-rates/[id]/rules/[rule_id]/route.ts new file mode 100644 index 0000000000000..4a8dacfa0210d --- /dev/null +++ b/packages/medusa/src/api-v2/admin/tax-rates/[id]/rules/[rule_id]/route.ts @@ -0,0 +1,33 @@ +import { deleteTaxRateRulesWorkflow } from "@medusajs/core-flows" +import { remoteQueryObjectFromString } from "@medusajs/utils" +import { defaultAdminTaxRatesFields } from "../../../../../../api/routes/admin/tax-rates" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../../../types/routing" + +export const DELETE = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { errors } = await deleteTaxRateRulesWorkflow(req.scope).run({ + input: { ids: [req.params.rule_id] }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const remoteQuery = req.scope.resolve("remoteQuery") + + const queryObject = remoteQueryObjectFromString({ + entryPoint: "tax_rate", + variables: { id: req.params.id }, + fields: defaultAdminTaxRatesFields, + }) + + const [taxRate] = await remoteQuery(queryObject) + + res.status(200).json({ tax_rate: taxRate }) +} diff --git a/packages/medusa/src/api-v2/admin/tax-rates/[id]/rules/route.ts b/packages/medusa/src/api-v2/admin/tax-rates/[id]/rules/route.ts new file mode 100644 index 0000000000000..b1d8fbb8e9ab0 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/tax-rates/[id]/rules/route.ts @@ -0,0 +1,42 @@ +import { createTaxRateRulesWorkflow } from "@medusajs/core-flows" +import { remoteQueryObjectFromString } from "@medusajs/utils" +import { defaultAdminTaxRatesFields } from "../../../../../api/routes/admin/tax-rates" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../../types/routing" +import { AdminPostTaxRatesTaxRateRulesReq } from "../../validators" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { errors } = await createTaxRateRulesWorkflow(req.scope).run({ + input: { + rules: [ + { + ...req.validatedBody, + tax_rate_id: req.params.id, + created_by: req.auth.actor_id, + }, + ], + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const remoteQuery = req.scope.resolve("remoteQuery") + + const queryObject = remoteQueryObjectFromString({ + entryPoint: "tax_rate", + variables: { id: req.params.id }, + fields: defaultAdminTaxRatesFields, + }) + + const [taxRate] = await remoteQuery(queryObject) + + res.status(200).json({ tax_rate: taxRate }) +} diff --git a/packages/medusa/src/api-v2/admin/tax-rates/middlewares.ts b/packages/medusa/src/api-v2/admin/tax-rates/middlewares.ts index ffdbe51d641f6..3c06e5757131a 100644 --- a/packages/medusa/src/api-v2/admin/tax-rates/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/tax-rates/middlewares.ts @@ -4,6 +4,7 @@ import { AdminGetTaxRatesTaxRateParams, AdminPostTaxRatesReq, AdminPostTaxRatesTaxRateReq, + AdminPostTaxRatesTaxRateRulesReq, } from "./validators" import { transformBody, transformQuery } from "../../../api/middlewares" @@ -36,4 +37,9 @@ export const adminTaxRateRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: "POST", + matcher: "/admin/tax-rates/:id/rules", + middlewares: [transformBody(AdminPostTaxRatesTaxRateRulesReq)], + }, ] diff --git a/packages/medusa/src/api-v2/admin/tax-rates/validators.ts b/packages/medusa/src/api-v2/admin/tax-rates/validators.ts index 938f2a6d15838..2c39c98d1b822 100644 --- a/packages/medusa/src/api-v2/admin/tax-rates/validators.ts +++ b/packages/medusa/src/api-v2/admin/tax-rates/validators.ts @@ -65,6 +65,10 @@ export class AdminPostTaxRatesTaxRateReq { @IsOptional() name?: string + @ValidateNested({ each: true }) + @Type(() => CreateTaxRateRule) + rules: CreateTaxRateRule[] + @IsBoolean() @IsOptional() is_default?: boolean @@ -77,3 +81,5 @@ export class AdminPostTaxRatesTaxRateReq { @IsOptional() metadata?: Record } + +export class AdminPostTaxRatesTaxRateRulesReq extends CreateTaxRateRule {} diff --git a/packages/medusa/src/loaders/api.ts b/packages/medusa/src/loaders/api.ts index 3f8f1b56a79c7..cbad34afe600b 100644 --- a/packages/medusa/src/loaders/api.ts +++ b/packages/medusa/src/loaders/api.ts @@ -47,7 +47,9 @@ export default async ({ configModule, }).load() } catch (err) { - throw Error("An error occurred while registering Medusa Core API Routes") + throw Error( + "An error occurred while registering Medusa Core API Routes. See error in logs for more details." + ) } } else { app.use(bodyParser.json()) diff --git a/packages/tax/integration-tests/__tests__/index.spec.ts b/packages/tax/integration-tests/__tests__/index.spec.ts index cd28f2f9ae3de..e6f1704fc3378 100644 --- a/packages/tax/integration-tests/__tests__/index.spec.ts +++ b/packages/tax/integration-tests/__tests__/index.spec.ts @@ -75,6 +75,89 @@ moduleIntegrationTestRunner({ ) }) + it("should update tax rates with rules", 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", + code: "test", + rate: 8.23, + }) + + await service.update(rate.id, { + name: "Updated Rate", + code: "TEST", + rate: 8.25, + rules: [ + { reference: "product", reference_id: "product_id_1" }, + { reference: "product_type", reference_id: "product_type_id" }, + ], + }) + + const rules = await service.listTaxRateRules({ tax_rate_id: rate.id }) + + expect(rules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + reference: "product", + reference_id: "product_id_1", + }), + expect.objectContaining({ + reference: "product_type", + reference_id: "product_type_id", + }), + ]) + ) + + await service.update(rate.id, { + rules: [ + { reference: "product", reference_id: "product_id_1" }, + { reference: "product", reference_id: "product_id_2" }, + { reference: "product_type", reference_id: "product_type_id_2" }, + { reference: "product_type", reference_id: "product_type_id_3" }, + ], + }) + + const rulesWithDeletes = await service.listTaxRateRules( + { tax_rate_id: rate.id }, + { withDeleted: true } + ) + + expect(rulesWithDeletes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + reference: "product", + reference_id: "product_id_2", + }), + expect.objectContaining({ + reference: "product_type", + reference_id: "product_type_id_2", + }), + expect.objectContaining({ + reference: "product_type", + reference_id: "product_type_id_3", + }), + expect.objectContaining({ + reference: "product", + reference_id: "product_id_1", + deleted_at: expect.any(Date), + }), + expect.objectContaining({ + reference: "product_type", + reference_id: "product_type_id", + deleted_at: expect.any(Date), + }), + ]) + ) + }) + it("should create a tax region", async () => { const region = await service.createTaxRegions({ country_code: "US", diff --git a/packages/tax/src/models/tax-rate-rule.ts b/packages/tax/src/models/tax-rate-rule.ts index 998d7e00f0400..a16b08dae18a4 100644 --- a/packages/tax/src/models/tax-rate-rule.ts +++ b/packages/tax/src/models/tax-rate-rule.ts @@ -105,11 +105,11 @@ export default class TaxRateRule { @BeforeCreate() onCreate() { - this.id = generateEntityId(this.id, "txr") + this.id = generateEntityId(this.id, "txrule") } @OnInit() onInit() { - this.id = generateEntityId(this.id, "txr") + this.id = generateEntityId(this.id, "txrule") } } diff --git a/packages/tax/src/models/tax-rate.ts b/packages/tax/src/models/tax-rate.ts index 77d5203dc30d6..cbc853549e57a 100644 --- a/packages/tax/src/models/tax-rate.ts +++ b/packages/tax/src/models/tax-rate.ts @@ -79,6 +79,7 @@ export default class TaxRate { @OneToMany(() => TaxRateRule, (rule) => rule.tax_rate, { cascade: ["soft-remove" as Cascade], + persist: false, }) rules = new Collection(this) diff --git a/packages/tax/src/services/tax-module-service.ts b/packages/tax/src/services/tax-module-service.ts index 626f3751cb1ad..caab1eac0be00 100644 --- a/packages/tax/src/services/tax-module-service.ts +++ b/packages/tax/src/services/tax-module-service.ts @@ -22,6 +22,7 @@ import { import { TaxProvider, TaxRate, TaxRegion, TaxRateRule } from "@models" import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" import { TaxRegionDTO } from "@medusajs/types" +import { EntityManager } from "@mikro-orm/postgresql" type InjectedDependencies = { baseRepository: DAL.RepositoryService @@ -135,6 +136,7 @@ export default class TaxModuleService< rateRules.map((r) => { return { ...r, + created_by: rate.created_by, tax_rate_id: rate.id, } }) @@ -193,9 +195,85 @@ export default class TaxModuleService< ? { id: idOrSelector } : idOrSelector + if (data.rules) { + await this.setTaxRateRulesForTaxRates( + idOrSelector, + data.rules, + data.updated_by, + sharedContext + ) + + delete data.rules + } + return await this.taxRateService_.update({ selector, data }, sharedContext) } + private async setTaxRateRulesForTaxRates( + idOrSelector: string | string[] | TaxTypes.FilterableTaxRateProps, + rules: Omit[], + createdBy?: string, + sharedContext: Context = {} + ) { + const selector = + Array.isArray(idOrSelector) || isString(idOrSelector) + ? { id: idOrSelector } + : idOrSelector + + await this.taxRateRuleService_.softDelete( + { tax_rate: selector }, + sharedContext + ) + + // TODO: this is a temporary solution seems like mikro-orm doesn't persist + // the soft delete which results in the creation below breaking the unique + // constraint + await this.taxRateRuleService_.list( + { tax_rate: selector }, + { select: ["id"] }, + sharedContext + ) + + if (rules.length === 0) { + return + } + + const rateIds = await this.getTaxRateIdsFromSelector(idOrSelector) + const toCreate = rateIds + .map((id) => { + return rules.map((r) => { + return { + ...r, + created_by: createdBy, + tax_rate_id: id, + } + }) + }) + .flat() + + return await this.createTaxRateRules(toCreate, sharedContext) + } + + private async getTaxRateIdsFromSelector( + idOrSelector: string | string[] | TaxTypes.FilterableTaxRateProps, + sharedContext: Context = {} + ) { + if (Array.isArray(idOrSelector)) { + return idOrSelector + } + + if (isString(idOrSelector)) { + return [idOrSelector] + } + + const rates = await this.taxRateService_.list( + idOrSelector, + { select: ["id"] }, + sharedContext + ) + return rates.map((r) => r.id) + } + async upsert( data: TaxTypes.UpsertTaxRateDTO[], sharedContext?: Context diff --git a/packages/types/src/tax/common.ts b/packages/types/src/tax/common.ts index 45e4b97e93571..b9c2c72567f7c 100644 --- a/packages/types/src/tax/common.ts +++ b/packages/types/src/tax/common.ts @@ -60,7 +60,7 @@ export interface TaxProviderDTO { export interface FilterableTaxRateProps extends BaseFilterable { id?: string | string[] - + tax_region_id?: string | string[] rate?: number | number[] | OperatorMap code?: string | string[] | OperatorMap name?: string | string[] | OperatorMap @@ -97,6 +97,7 @@ export interface FilterableTaxRegionProps } export interface TaxRateRuleDTO { + id: string reference: string reference_id: string tax_rate_id: string diff --git a/packages/types/src/tax/mutations.ts b/packages/types/src/tax/mutations.ts index 39e302e3ac852..2a30bbdfbac43 100644 --- a/packages/types/src/tax/mutations.ts +++ b/packages/types/src/tax/mutations.ts @@ -23,8 +23,10 @@ export interface UpdateTaxRateDTO { rate?: number | null code?: string | null name?: string + rules?: Omit[] is_default?: boolean - created_by?: string + is_combinable?: boolean + updated_by?: string metadata?: Record } @@ -47,5 +49,5 @@ export interface CreateTaxRateRuleDTO { reference_id: string tax_rate_id: string metadata?: Record - created_by?: string + created_by?: string | null } diff --git a/packages/types/src/tax/service.ts b/packages/types/src/tax/service.ts index 34f719b30f9c3..691786dea29d7 100644 --- a/packages/types/src/tax/service.ts +++ b/packages/types/src/tax/service.ts @@ -110,11 +110,11 @@ export interface ITaxModuleService extends IModuleService { ): Promise deleteTaxRateRules( - taxRateRulePair: { tax_rate_id: string; reference_id: string }, + taxRateRuleId: string, sharedContext?: Context ): Promise deleteTaxRateRules( - taxRateRulePair: { tax_rate_id: string; reference_id: string }[], + taxRateRuleIds: string[], sharedContext?: Context ): Promise @@ -149,8 +149,14 @@ export interface ITaxModuleService extends IModuleService { ): Promise | void> softDeleteTaxRateRules( - taxRateRulePairs: { tax_rate_id: string; reference_id: string }[], + taxRateRuleIds: string[], config?: SoftDeleteReturn, sharedContext?: Context ): Promise | void> + + restoreTaxRateRules( + taxRateRuleIds: string[], + config?: RestoreReturn, + sharedContext?: Context + ): Promise | void> }