555eb41fca
**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).
512 lines
13 KiB
TypeScript
512 lines
13 KiB
TypeScript
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"
|
|
|
|
jest.setTimeout(50000)
|
|
|
|
const env = { MEDUSA_FF_MEDUSA_V2: true }
|
|
const adminHeaders = {
|
|
headers: { "x-medusa-access-token": "test_token" },
|
|
}
|
|
|
|
describe("Taxes - Admin", () => {
|
|
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("can retrieve a tax rate", async () => {
|
|
const region = await service.createTaxRegions({
|
|
country_code: "us",
|
|
})
|
|
const rate = await service.create({
|
|
tax_region_id: region.id,
|
|
code: "test",
|
|
rate: 2.5,
|
|
name: "Test Rate",
|
|
})
|
|
|
|
const api = useApi() as any
|
|
const response = await api.get(`/admin/tax-rates/${rate.id}`, adminHeaders)
|
|
|
|
expect(response.status).toEqual(200)
|
|
expect(response.data).toEqual({
|
|
tax_rate: {
|
|
id: rate.id,
|
|
code: "test",
|
|
rate: 2.5,
|
|
name: "Test Rate",
|
|
metadata: null,
|
|
tax_region_id: region.id,
|
|
is_default: false,
|
|
is_combinable: false,
|
|
created_at: expect.any(String),
|
|
updated_at: expect.any(String),
|
|
deleted_at: null,
|
|
created_by: null,
|
|
},
|
|
})
|
|
})
|
|
|
|
it("can create a tax region with rates and rules", 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
|
|
|
|
expect(regionRes.status).toEqual(200)
|
|
expect(regionRes.data).toEqual({
|
|
tax_region: {
|
|
id: expect.any(String),
|
|
country_code: "us",
|
|
parent_id: null,
|
|
province_code: null,
|
|
created_at: expect.any(String),
|
|
updated_at: expect.any(String),
|
|
deleted_at: null,
|
|
created_by: "admin_user",
|
|
provider_id: null,
|
|
metadata: null,
|
|
},
|
|
})
|
|
|
|
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
|
|
)
|
|
|
|
expect(rateRes.status).toEqual(200)
|
|
expect(rateRes.data).toEqual({
|
|
tax_rate: {
|
|
id: expect.any(String),
|
|
code: "RATE2",
|
|
rate: 10,
|
|
name: "another rate",
|
|
is_default: false,
|
|
metadata: null,
|
|
tax_region_id: usRegionId,
|
|
created_at: expect.any(String),
|
|
updated_at: expect.any(String),
|
|
deleted_at: null,
|
|
created_by: "admin_user",
|
|
is_combinable: false,
|
|
},
|
|
})
|
|
|
|
const provRegRes = await api.post(
|
|
`/admin/tax-regions`,
|
|
{
|
|
country_code: "US",
|
|
parent_id: usRegionId,
|
|
province_code: "cA",
|
|
},
|
|
adminHeaders
|
|
)
|
|
|
|
expect(provRegRes.status).toEqual(200)
|
|
expect(provRegRes.data).toEqual({
|
|
tax_region: {
|
|
id: expect.any(String),
|
|
country_code: "us",
|
|
parent_id: usRegionId,
|
|
province_code: "ca",
|
|
created_at: expect.any(String),
|
|
updated_at: expect.any(String),
|
|
deleted_at: null,
|
|
created_by: "admin_user",
|
|
metadata: null,
|
|
provider_id: null,
|
|
},
|
|
})
|
|
|
|
const defRes = await api.post(
|
|
`/admin/tax-rates`,
|
|
{
|
|
tax_region_id: provRegRes.data.tax_region.id,
|
|
code: "DEFAULT",
|
|
name: "DEFAULT",
|
|
rate: 10,
|
|
is_default: true,
|
|
},
|
|
adminHeaders
|
|
)
|
|
|
|
const listRes = await api.get(`/admin/tax-rates`, adminHeaders)
|
|
|
|
expect(listRes.status).toEqual(200)
|
|
expect(listRes.data.tax_rates).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: rateRes.data.tax_rate.id,
|
|
code: "RATE2",
|
|
rate: 10,
|
|
name: "another rate",
|
|
is_default: false,
|
|
metadata: null,
|
|
tax_region_id: usRegionId,
|
|
created_at: expect.any(String),
|
|
updated_at: expect.any(String),
|
|
deleted_at: null,
|
|
created_by: "admin_user",
|
|
}),
|
|
expect.objectContaining({ id: defRes.data.tax_rate.id }),
|
|
expect.objectContaining({
|
|
tax_region_id: usRegionId,
|
|
is_default: true,
|
|
rate: 2,
|
|
}),
|
|
])
|
|
)
|
|
})
|
|
|
|
it("can create a tax rate and update it", 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
|
|
|
|
expect(regionRes.status).toEqual(200)
|
|
expect(regionRes.data).toEqual({
|
|
tax_region: {
|
|
id: expect.any(String),
|
|
country_code: "us",
|
|
parent_id: null,
|
|
province_code: null,
|
|
created_at: expect.any(String),
|
|
updated_at: expect.any(String),
|
|
deleted_at: null,
|
|
created_by: "admin_user",
|
|
provider_id: null,
|
|
metadata: null,
|
|
},
|
|
})
|
|
|
|
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
|
|
)
|
|
|
|
expect(rateRes.status).toEqual(200)
|
|
expect(rateRes.data).toEqual({
|
|
tax_rate: {
|
|
id: expect.any(String),
|
|
code: "RATE2",
|
|
rate: 10,
|
|
name: "another rate",
|
|
is_default: false,
|
|
metadata: null,
|
|
tax_region_id: usRegionId,
|
|
created_at: expect.any(String),
|
|
updated_at: expect.any(String),
|
|
deleted_at: null,
|
|
created_by: "admin_user",
|
|
is_combinable: false,
|
|
},
|
|
})
|
|
|
|
const updateRes = await api.post(
|
|
`/admin/tax-rates/${rateRes.data.tax_rate.id}`,
|
|
{
|
|
code: "updatedcode",
|
|
rate: 12,
|
|
is_combinable: true,
|
|
name: "Another Name",
|
|
metadata: { you: "know it" },
|
|
},
|
|
adminHeaders
|
|
)
|
|
|
|
expect(updateRes.status).toEqual(200)
|
|
expect(updateRes.data).toEqual({
|
|
tax_rate: {
|
|
id: expect.any(String),
|
|
code: "updatedcode",
|
|
rate: 12,
|
|
name: "Another Name",
|
|
is_default: false,
|
|
metadata: { you: "know it" },
|
|
tax_region_id: usRegionId,
|
|
deleted_at: null,
|
|
created_at: expect.any(String),
|
|
updated_at: expect.any(String),
|
|
created_by: "admin_user",
|
|
is_combinable: true,
|
|
},
|
|
})
|
|
})
|
|
|
|
it("can create a tax rate and delete it", 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 deleteRes = await api.delete(
|
|
`/admin/tax-rates/${rateRes.data.tax_rate.id}`,
|
|
adminHeaders
|
|
)
|
|
|
|
expect(deleteRes.status).toEqual(200)
|
|
expect(deleteRes.data).toEqual({
|
|
id: rateRes.data.tax_rate.id,
|
|
object: "tax_rate",
|
|
deleted: true,
|
|
})
|
|
|
|
const rates = await service.list(
|
|
{ id: rateRes.data.tax_rate.id },
|
|
{ withDeleted: true }
|
|
)
|
|
expect(rates.length).toEqual(1)
|
|
expect(rates[0].deleted_at).not.toBeNull()
|
|
})
|
|
|
|
it("can create a tax region and delete it", 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 deleteRes = await api.delete(
|
|
`/admin/tax-regions/${usRegionId}`,
|
|
adminHeaders
|
|
)
|
|
|
|
expect(deleteRes.status).toEqual(200)
|
|
expect(deleteRes.data).toEqual({
|
|
id: usRegionId,
|
|
object: "tax_region",
|
|
deleted: true,
|
|
})
|
|
|
|
const rates = await service.listTaxRegions(
|
|
{ id: usRegionId },
|
|
{ withDeleted: true }
|
|
)
|
|
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",
|
|
}),
|
|
])
|
|
)
|
|
})
|
|
})
|