feat: Add support in BE for setting tax inclusivity on currency (#8037)

This commit is contained in:
Stevche Radevski
2024-07-09 15:22:24 +02:00
committed by GitHub
parent 4c89f91caf
commit 00a6e512dc
15 changed files with 287 additions and 37 deletions

View File

@@ -1269,6 +1269,43 @@ medusaIntegrationTestRunner({
let euCart let euCart
beforeEach(async () => { beforeEach(async () => {
const store = (await api.get("/admin/stores", adminHeaders)).data
.stores[0]
if (store) {
await api.post(
`/admin/stores/${store.id}`,
{
supported_currencies: [
{
currency_code: "usd",
is_tax_inclusive: true,
is_default: true,
},
{ currency_code: "eur", is_tax_inclusive: false },
{ currency_code: "dkk", is_tax_inclusive: true },
],
},
adminHeaders
)
} else {
await api.post(
"/admin/stores",
{
name: "Test store",
supported_currencies: [
{
currency_code: "usd",
is_tax_inclusive: true,
is_default: true,
},
{ currency_code: "eur", is_tax_inclusive: false },
{ currency_code: "dkk", is_tax_inclusive: true },
],
},
adminHeaders
)
}
usRegion = ( usRegion = (
await api.post( await api.post(
"/admin/regions", "/admin/regions",
@@ -1344,6 +1381,14 @@ medusaIntegrationTestRunner({
) )
).data.product ).data.product
product2 = (
await api.post(
"/admin/products",
getProductFixture({ title: "test2", status: "published" }),
adminHeaders
)
).data.product
euCart = (await api.post("/store/carts", { region_id: euRegion.id })) euCart = (await api.post("/store/carts", { region_id: euRegion.id }))
.data.cart .data.cart
@@ -1394,7 +1439,7 @@ medusaIntegrationTestRunner({
) )
).data.products ).data.products
expect(products.length).toBe(1) expect(products.length).toBe(2)
expect(products[0].variants[0].calculated_price).not.toHaveProperty( expect(products[0].variants[0].calculated_price).not.toHaveProperty(
"calculated_amount_with_tax" "calculated_amount_with_tax"
) )
@@ -1410,7 +1455,7 @@ medusaIntegrationTestRunner({
) )
).data.products ).data.products
expect(products.length).toBe(1) expect(products.length).toBe(2)
expect(products[0].variants[0].calculated_price).not.toHaveProperty( expect(products[0].variants[0].calculated_price).not.toHaveProperty(
"calculated_amount_with_tax" "calculated_amount_with_tax"
) )
@@ -1426,7 +1471,7 @@ medusaIntegrationTestRunner({
) )
).data.products ).data.products
expect(products.length).toBe(1) expect(products.length).toBe(2)
expect(products[0].variants).toEqual( expect(products[0].variants).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
@@ -1445,6 +1490,19 @@ medusaIntegrationTestRunner({
1 1
) )
).toEqual("40.9") ).toEqual("40.9")
expect(products[1].variants).toEqual(
expect.arrayContaining([
expect.objectContaining({
calculated_price: expect.objectContaining({
currency_code: "eur",
calculated_amount: 45,
calculated_amount_without_tax: 45,
calculated_amount_with_tax: 49.5,
}),
}),
])
)
}) })
it("should return prices with and without tax for a tax exclusive region when listing products", async () => { it("should return prices with and without tax for a tax exclusive region when listing products", async () => {
@@ -1454,7 +1512,7 @@ medusaIntegrationTestRunner({
) )
).data.products ).data.products
expect(products.length).toBe(1) expect(products.length).toBe(2)
expect(products[0].variants).toEqual( expect(products[0].variants).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
@@ -1467,6 +1525,18 @@ medusaIntegrationTestRunner({
}), }),
]) ])
) )
expect(products[1].variants).toEqual(
expect.arrayContaining([
expect.objectContaining({
calculated_price: expect.objectContaining({
currency_code: "dkk",
calculated_amount: 30,
calculated_amount_with_tax: 30,
calculated_amount_without_tax: 25,
}),
}),
])
)
}) })
it("should return prices with and without tax when the cart is available and a country is passed when listing products", async () => { it("should return prices with and without tax when the cart is available and a country is passed when listing products", async () => {
@@ -1476,7 +1546,7 @@ medusaIntegrationTestRunner({
) )
).data.products ).data.products
expect(products.length).toBe(1) expect(products.length).toBe(2)
expect(products[0].variants).toEqual( expect(products[0].variants).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
@@ -1510,7 +1580,7 @@ medusaIntegrationTestRunner({
) )
).data.products ).data.products
expect(products.length).toBe(1) expect(products.length).toBe(2)
expect(products[0].variants).toEqual( expect(products[0].variants).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({

View File

@@ -118,6 +118,8 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => {
supported_currencies: currencies.map((c) => ({ supported_currencies: currencies.map((c) => ({
currency_code: c, currency_code: c,
is_default: c === defaultCurrency, is_default: c === defaultCurrency,
// TODO: Add UI to manage this
is_tax_inclsuive: false,
})), })),
}) })
toast.success(t("general.success"), { toast.success(t("general.success"), {

View File

@@ -2,4 +2,5 @@ export * from "./create-price-sets"
export * from "./update-price-sets" export * from "./update-price-sets"
export * from "./create-price-preferences" export * from "./create-price-preferences"
export * from "./update-price-preferences" export * from "./update-price-preferences"
export * from "./update-price-preferences-as-array"
export * from "./delete-price-preferences" export * from "./delete-price-preferences"

View File

@@ -0,0 +1,76 @@
import { PricingWorkflow, IPricingModuleService } from "@medusajs/types"
import {
MedusaError,
ModuleRegistrationName,
arrayDifference,
} from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
type StepInput = PricingWorkflow.UpdatePricePreferencesWorkflowInput["update"][]
export const updatePricePreferencesAsArrayStepId =
"update-price-preferences-as-array"
export const updatePricePreferencesAsArrayStep = createStep(
updatePricePreferencesAsArrayStepId,
async (input: StepInput, { container }) => {
const service = container.resolve<IPricingModuleService>(
ModuleRegistrationName.PRICING
)
const prevData = await service.listPricePreferences({
$or: input.map(
(entry) => {
if (!entry.attribute || !entry.value) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Attribute and value must be provided when updating price preferences"
)
}
return { attribute: entry.attribute, value: entry.value }
},
{ take: null }
),
})
const toUpsert = input.map((entry) => {
const prevEntry = prevData.find(
(prevEntry) =>
prevEntry.attribute === entry.attribute &&
prevEntry.value === entry.value
)
return {
id: prevEntry?.id,
attribute: entry.attribute,
value: entry.value,
is_tax_inclusive: entry.is_tax_inclusive ?? prevEntry?.is_tax_inclusive,
}
})
const upsertedPricePreferences = await service.upsertPricePreferences(
toUpsert
)
const newIds = arrayDifference(
upsertedPricePreferences.map((p) => p.id),
prevData.map((p) => p.id)
)
return new StepResponse(upsertedPricePreferences, {
prevData,
newDataIds: newIds,
})
},
async (compensationData, { container }) => {
if (!compensationData) {
return
}
const service = container.resolve<IPricingModuleService>(
ModuleRegistrationName.PRICING
)
await service.upsertPricePreferences(compensationData.prevData)
await service.deletePricePreferences(compensationData.newDataIds)
}
)

View File

@@ -2,9 +2,7 @@ import { CreateStoreDTO, IStoreModuleService } from "@medusajs/types"
import { ModuleRegistrationName } from "@medusajs/utils" import { ModuleRegistrationName } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk" import { StepResponse, createStep } from "@medusajs/workflows-sdk"
type CreateStoresStepInput = { type CreateStoresStepInput = CreateStoreDTO[]
stores: CreateStoreDTO[]
}
export const createStoresStepId = "create-stores" export const createStoresStepId = "create-stores"
export const createStoresStep = createStep( export const createStoresStep = createStep(
@@ -14,7 +12,7 @@ export const createStoresStep = createStep(
ModuleRegistrationName.STORE ModuleRegistrationName.STORE
) )
const created = await service.createStores(data.stores) const created = await service.createStores(data)
return new StepResponse( return new StepResponse(
created, created,
created.map((store) => store.id) created.map((store) => store.id)

View File

@@ -9,7 +9,7 @@ import {
} from "@medusajs/utils" } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk" import { StepResponse, createStep } from "@medusajs/workflows-sdk"
type UpdateStoresStepInput = { type WorkflowInputData = {
selector: FilterableStoreProps selector: FilterableStoreProps
update: UpdateStoreDTO update: UpdateStoreDTO
} }
@@ -17,7 +17,7 @@ type UpdateStoresStepInput = {
export const updateStoresStepId = "update-stores" export const updateStoresStepId = "update-stores"
export const updateStoresStep = createStep( export const updateStoresStep = createStep(
updateStoresStepId, updateStoresStepId,
async (data: UpdateStoresStepInput, { container }) => { async (data: WorkflowInputData, { container }) => {
const service = container.resolve<IStoreModuleService>( const service = container.resolve<IStoreModuleService>(
ModuleRegistrationName.STORE ModuleRegistrationName.STORE
) )

View File

@@ -1,13 +1,54 @@
import { StoreDTO, CreateStoreDTO } from "@medusajs/types" import { StoreDTO, StoreWorkflow } from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" import {
WorkflowData,
createWorkflow,
transform,
} from "@medusajs/workflows-sdk"
import { createStoresStep } from "../steps" import { createStoresStep } from "../steps"
import { updatePricePreferencesAsArrayStep } from "../../pricing"
type WorkflowInput = { stores: CreateStoreDTO[] } type WorkflowInputData = { stores: StoreWorkflow.CreateStoreWorkflowInput[] }
export const createStoresWorkflowId = "create-stores" export const createStoresWorkflowId = "create-stores"
export const createStoresWorkflow = createWorkflow( export const createStoresWorkflow = createWorkflow(
createStoresWorkflowId, createStoresWorkflowId,
(input: WorkflowData<WorkflowInput>): WorkflowData<StoreDTO[]> => { (input: WorkflowData<WorkflowInputData>): WorkflowData<StoreDTO[]> => {
return createStoresStep(input) const normalizedInput = transform({ input }, (data) => {
return data.input.stores.map((store) => {
return {
...store,
supported_currencies: store.supported_currencies?.map((currency) => {
return {
currency_code: currency.currency_code,
is_default: currency.is_default,
}
}),
}
})
})
const stores = createStoresStep(normalizedInput)
const upsertPricePreferences = transform({ input }, (data) => {
const toUpsert = new Map<
string,
{ attribute: string; value: string; is_tax_inclusive?: boolean }
>()
data.input.stores.forEach((store) => {
store.supported_currencies.forEach((currency) => {
toUpsert.set(currency.currency_code, {
attribute: "currency_code",
value: currency.currency_code,
is_tax_inclusive: currency.is_tax_inclusive,
})
})
})
return Array.from(toUpsert.values())
})
updatePricePreferencesAsArrayStep(upsertPricePreferences)
return stores
} }
) )

View File

@@ -1,18 +1,58 @@
import { StoreDTO, FilterableStoreProps, UpdateStoreDTO } from "@medusajs/types" import { StoreDTO, StoreWorkflow } from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" import {
WorkflowData,
createWorkflow,
transform,
when,
} from "@medusajs/workflows-sdk"
import { updateStoresStep } from "../steps" import { updateStoresStep } from "../steps"
import { updatePricePreferencesAsArrayStep } from "../../pricing"
type UpdateStoresStepInput = { type WorkflowInputData = StoreWorkflow.UpdateStoreWorkflowInput
selector: FilterableStoreProps
update: UpdateStoreDTO
}
type WorkflowInput = UpdateStoresStepInput
export const updateStoresWorkflowId = "update-stores" export const updateStoresWorkflowId = "update-stores"
export const updateStoresWorkflow = createWorkflow( export const updateStoresWorkflow = createWorkflow(
updateStoresWorkflowId, updateStoresWorkflowId,
(input: WorkflowData<WorkflowInput>): WorkflowData<StoreDTO[]> => { (input: WorkflowData<WorkflowInputData>): WorkflowData<StoreDTO[]> => {
return updateStoresStep(input) const normalizedInput = transform({ input }, (data) => {
if (!data.input.update.supported_currencies?.length) {
return data.input
}
return {
selector: data.input.selector,
update: {
...data.input.update,
supported_currencies: data.input.update.supported_currencies.map(
(currency) => {
return {
currency_code: currency.currency_code,
is_default: currency.is_default,
}
}
),
},
}
})
const stores = updateStoresStep(normalizedInput)
when({ input }, (data) => {
return !!data.input.update.supported_currencies?.length
}).then(() => {
const upsertPricePreferences = transform({ input }, (data) => {
return data.input.update.supported_currencies!.map((currency) => {
return {
attribute: "currency_code",
value: currency.currency_code,
is_tax_inclusive: currency.is_tax_inclusive,
}
})
})
updatePricePreferencesAsArrayStep(upsertPricePreferences)
})
return stores
} }
) )

View File

@@ -1,10 +1,11 @@
interface AdminUpdateStoreSupportedCurrency { interface AdminUpdateStoreSupportedCurrency {
currency_code: string
is_default?: boolean is_default?: boolean
code: string is_tax_inclusive?: boolean
} }
export interface AdminUpdateStore { export interface AdminUpdateStore {
name?: string | null name?: string
supported_currencies?: AdminUpdateStoreSupportedCurrency[] supported_currencies?: AdminUpdateStoreSupportedCurrency[]
default_sales_channel_id?: string | null default_sales_channel_id?: string | null
default_region_id?: string | null default_region_id?: string | null

View File

@@ -71,20 +71,20 @@ export interface UpdateStoreDTO {
/** /**
* The associated default sales channel's ID. * The associated default sales channel's ID.
*/ */
default_sales_channel_id?: string default_sales_channel_id?: string | null
/** /**
* The associated default region's ID. * The associated default region's ID.
*/ */
default_region_id?: string default_region_id?: string | null
/** /**
* The associated default location's ID. * The associated default location's ID.
*/ */
default_location_id?: string default_location_id?: string | null
/** /**
* Holds custom data in key-value pairs. * Holds custom data in key-value pairs.
*/ */
metadata?: Record<string, any> metadata?: Record<string, any> | null
} }

View File

@@ -11,3 +11,4 @@ export * as ReservationWorkflow from "./reservation"
export * as UserWorkflow from "./user" export * as UserWorkflow from "./user"
export * as OrderWorkflow from "./order" export * as OrderWorkflow from "./order"
export * as PricingWorkflow from "./pricing" export * as PricingWorkflow from "./pricing"
export * as StoreWorkflow from "./store"

View File

@@ -0,0 +1,18 @@
import { AdminUpdateStore } from "../../http"
import { CreateStoreDTO, FilterableStoreProps } from "../../store"
export type CreateStoreWorkflowInput = Omit<
CreateStoreDTO,
"supported_currencies"
> & {
supported_currencies: {
currency_code: string
is_default?: boolean
is_tax_inclusive?: boolean
}[]
}
export interface UpdateStoreWorkflowInput {
selector: FilterableStoreProps
update: AdminUpdateStore
}

View File

@@ -1,5 +1,4 @@
import { updateStoresWorkflow } from "@medusajs/core-flows" import { updateStoresWorkflow } from "@medusajs/core-flows"
import { UpdateStoreDTO } from "@medusajs/types"
import { import {
remoteQueryObjectFromString, remoteQueryObjectFromString,
ContainerRegistrationKeys, ContainerRegistrationKeys,
@@ -35,7 +34,7 @@ export const POST = async (
const { result } = await updateStoresWorkflow(req.scope).run({ const { result } = await updateStoresWorkflow(req.scope).run({
input: { input: {
selector: { id: req.params.id }, selector: { id: req.params.id },
update: req.validatedBody as UpdateStoreDTO, update: req.validatedBody,
}, },
}) })

View File

@@ -20,12 +20,13 @@ export const AdminGetStoresParams = createFindParams({
export type AdminUpdateStoreType = z.infer<typeof AdminUpdateStore> export type AdminUpdateStoreType = z.infer<typeof AdminUpdateStore>
export const AdminUpdateStore = z.object({ export const AdminUpdateStore = z.object({
name: z.string().nullish(), name: z.string().optional(),
supported_currencies: z supported_currencies: z
.array( .array(
z.object({ z.object({
currency_code: z.string(), currency_code: z.string(),
is_default: z.boolean().optional(), is_default: z.boolean().optional(),
is_tax_inclusive: z.boolean().optional(),
}) })
) )
.optional(), .optional(),

View File

@@ -196,7 +196,9 @@ export default class StoreModuleService
) )
} }
private static validateCreateRequest(stores: StoreTypes.CreateStoreDTO[]) { private static validateCreateRequest(
stores: StoreTypes.CreateStoreDTO[] | StoreTypes.UpdateStoreDTO[]
) {
for (const store of stores) { for (const store of stores) {
if (store.supported_currencies?.length) { if (store.supported_currencies?.length) {
const duplicates = getDuplicates( const duplicates = getDuplicates(