diff --git a/integration-tests/modules/__tests__/tax/admin/tax.spec.ts b/integration-tests/modules/__tests__/tax/admin/tax.spec.ts index 23c675cbad..9a4e7efce3 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 0000000000..ea6a57e0d1 --- /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 0000000000..f73e347770 --- /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 0000000000..9003b4bdf7 --- /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 5bb861dab3..12daa23ac3 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 0000000000..30da06c237 --- /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 0000000000..8f1fa65c2b --- /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 0000000000..f6f5bc1f57 --- /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 0000000000..64a1a5f58c --- /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 5bb861dab3..962cb32961 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 0000000000..d31e6da345 --- /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 16a10b59f3..fe5c8ae670 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 6f8fdf6b31..51aa8f0baf 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 0000000000..4a8dacfa02 --- /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 0000000000..b1d8fbb8e9 --- /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 ffdbe51d64..3c06e57571 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 938f2a6d15..2c39c98d1b 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 3f8f1b56a7..cbad34afe6 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 cd28f2f9ae..e6f1704fc3 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 998d7e00f0..a16b08dae1 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 77d5203dc3..cbc853549e 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 626f3751cb..caab1eac0b 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 45e4b97e93..b9c2c72567 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 39e302e3ac..2a30bbdfba 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 34f719b30f..691786dea2 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> }