From 00a6e512dca3bfd4b520b86c07ef639ccfc4a6c8 Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Tue, 9 Jul 2024 15:22:24 +0200 Subject: [PATCH] feat: Add support in BE for setting tax inclusivity on currency (#8037) --- .../__tests__/product/store/product.spec.ts | 82 +++++++++++++++++-- .../add-currencies-form.tsx | 2 + .../core-flows/src/pricing/steps/index.ts | 1 + .../update-price-preferences-as-array.ts | 76 +++++++++++++++++ .../src/store/steps/create-stores.ts | 6 +- .../src/store/steps/update-stores.ts | 4 +- .../src/store/workflows/create-stores.ts | 51 ++++++++++-- .../src/store/workflows/update-stores.ts | 60 +++++++++++--- .../types/src/http/store/admin/payloads.ts | 5 +- .../core/types/src/store/mutations/store.ts | 8 +- packages/core/types/src/workflow/index.ts | 1 + .../core/types/src/workflow/store/index.ts | 18 ++++ .../medusa/src/api/admin/stores/[id]/route.ts | 3 +- .../medusa/src/api/admin/stores/validators.ts | 3 +- .../src/services/store-module-service.ts | 4 +- 15 files changed, 287 insertions(+), 37 deletions(-) create mode 100644 packages/core/core-flows/src/pricing/steps/update-price-preferences-as-array.ts create mode 100644 packages/core/types/src/workflow/store/index.ts diff --git a/integration-tests/http/__tests__/product/store/product.spec.ts b/integration-tests/http/__tests__/product/store/product.spec.ts index 22e1ebdc71..cf4795a176 100644 --- a/integration-tests/http/__tests__/product/store/product.spec.ts +++ b/integration-tests/http/__tests__/product/store/product.spec.ts @@ -1269,6 +1269,43 @@ medusaIntegrationTestRunner({ let euCart 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 = ( await api.post( "/admin/regions", @@ -1344,6 +1381,14 @@ medusaIntegrationTestRunner({ ) ).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 })) .data.cart @@ -1394,7 +1439,7 @@ medusaIntegrationTestRunner({ ) ).data.products - expect(products.length).toBe(1) + expect(products.length).toBe(2) expect(products[0].variants[0].calculated_price).not.toHaveProperty( "calculated_amount_with_tax" ) @@ -1410,7 +1455,7 @@ medusaIntegrationTestRunner({ ) ).data.products - expect(products.length).toBe(1) + expect(products.length).toBe(2) expect(products[0].variants[0].calculated_price).not.toHaveProperty( "calculated_amount_with_tax" ) @@ -1426,7 +1471,7 @@ medusaIntegrationTestRunner({ ) ).data.products - expect(products.length).toBe(1) + expect(products.length).toBe(2) expect(products[0].variants).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -1445,6 +1490,19 @@ medusaIntegrationTestRunner({ 1 ) ).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 () => { @@ -1454,7 +1512,7 @@ medusaIntegrationTestRunner({ ) ).data.products - expect(products.length).toBe(1) + expect(products.length).toBe(2) expect(products[0].variants).toEqual( expect.arrayContaining([ 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 () => { @@ -1476,7 +1546,7 @@ medusaIntegrationTestRunner({ ) ).data.products - expect(products.length).toBe(1) + expect(products.length).toBe(2) expect(products[0].variants).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -1510,7 +1580,7 @@ medusaIntegrationTestRunner({ ) ).data.products - expect(products.length).toBe(1) + expect(products.length).toBe(2) expect(products[0].variants).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/packages/admin-next/dashboard/src/routes/store/store-add-currencies/components/add-currencies-form/add-currencies-form.tsx b/packages/admin-next/dashboard/src/routes/store/store-add-currencies/components/add-currencies-form/add-currencies-form.tsx index 67f452e2d4..a08766a058 100644 --- a/packages/admin-next/dashboard/src/routes/store/store-add-currencies/components/add-currencies-form/add-currencies-form.tsx +++ b/packages/admin-next/dashboard/src/routes/store/store-add-currencies/components/add-currencies-form/add-currencies-form.tsx @@ -118,6 +118,8 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => { supported_currencies: currencies.map((c) => ({ currency_code: c, is_default: c === defaultCurrency, + // TODO: Add UI to manage this + is_tax_inclsuive: false, })), }) toast.success(t("general.success"), { diff --git a/packages/core/core-flows/src/pricing/steps/index.ts b/packages/core/core-flows/src/pricing/steps/index.ts index 5d4820f2e0..1a0e68070d 100644 --- a/packages/core/core-flows/src/pricing/steps/index.ts +++ b/packages/core/core-flows/src/pricing/steps/index.ts @@ -2,4 +2,5 @@ export * from "./create-price-sets" export * from "./update-price-sets" export * from "./create-price-preferences" export * from "./update-price-preferences" +export * from "./update-price-preferences-as-array" export * from "./delete-price-preferences" diff --git a/packages/core/core-flows/src/pricing/steps/update-price-preferences-as-array.ts b/packages/core/core-flows/src/pricing/steps/update-price-preferences-as-array.ts new file mode 100644 index 0000000000..154a758dc0 --- /dev/null +++ b/packages/core/core-flows/src/pricing/steps/update-price-preferences-as-array.ts @@ -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( + 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( + ModuleRegistrationName.PRICING + ) + + await service.upsertPricePreferences(compensationData.prevData) + await service.deletePricePreferences(compensationData.newDataIds) + } +) diff --git a/packages/core/core-flows/src/store/steps/create-stores.ts b/packages/core/core-flows/src/store/steps/create-stores.ts index 82a1aa79bb..76af9af2ec 100644 --- a/packages/core/core-flows/src/store/steps/create-stores.ts +++ b/packages/core/core-flows/src/store/steps/create-stores.ts @@ -2,9 +2,7 @@ import { CreateStoreDTO, IStoreModuleService } from "@medusajs/types" import { ModuleRegistrationName } from "@medusajs/utils" import { StepResponse, createStep } from "@medusajs/workflows-sdk" -type CreateStoresStepInput = { - stores: CreateStoreDTO[] -} +type CreateStoresStepInput = CreateStoreDTO[] export const createStoresStepId = "create-stores" export const createStoresStep = createStep( @@ -14,7 +12,7 @@ export const createStoresStep = createStep( ModuleRegistrationName.STORE ) - const created = await service.createStores(data.stores) + const created = await service.createStores(data) return new StepResponse( created, created.map((store) => store.id) diff --git a/packages/core/core-flows/src/store/steps/update-stores.ts b/packages/core/core-flows/src/store/steps/update-stores.ts index 52dd6fd9d8..5cd7368069 100644 --- a/packages/core/core-flows/src/store/steps/update-stores.ts +++ b/packages/core/core-flows/src/store/steps/update-stores.ts @@ -9,7 +9,7 @@ import { } from "@medusajs/utils" import { StepResponse, createStep } from "@medusajs/workflows-sdk" -type UpdateStoresStepInput = { +type WorkflowInputData = { selector: FilterableStoreProps update: UpdateStoreDTO } @@ -17,7 +17,7 @@ type UpdateStoresStepInput = { export const updateStoresStepId = "update-stores" export const updateStoresStep = createStep( updateStoresStepId, - async (data: UpdateStoresStepInput, { container }) => { + async (data: WorkflowInputData, { container }) => { const service = container.resolve( ModuleRegistrationName.STORE ) diff --git a/packages/core/core-flows/src/store/workflows/create-stores.ts b/packages/core/core-flows/src/store/workflows/create-stores.ts index d4458dabdf..7408e126a3 100644 --- a/packages/core/core-flows/src/store/workflows/create-stores.ts +++ b/packages/core/core-flows/src/store/workflows/create-stores.ts @@ -1,13 +1,54 @@ -import { StoreDTO, CreateStoreDTO } from "@medusajs/types" -import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { StoreDTO, StoreWorkflow } from "@medusajs/types" +import { + WorkflowData, + createWorkflow, + transform, +} from "@medusajs/workflows-sdk" import { createStoresStep } from "../steps" +import { updatePricePreferencesAsArrayStep } from "../../pricing" -type WorkflowInput = { stores: CreateStoreDTO[] } +type WorkflowInputData = { stores: StoreWorkflow.CreateStoreWorkflowInput[] } export const createStoresWorkflowId = "create-stores" export const createStoresWorkflow = createWorkflow( createStoresWorkflowId, - (input: WorkflowData): WorkflowData => { - return createStoresStep(input) + (input: WorkflowData): WorkflowData => { + 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 } ) diff --git a/packages/core/core-flows/src/store/workflows/update-stores.ts b/packages/core/core-flows/src/store/workflows/update-stores.ts index d5c08693eb..5c307d5be5 100644 --- a/packages/core/core-flows/src/store/workflows/update-stores.ts +++ b/packages/core/core-flows/src/store/workflows/update-stores.ts @@ -1,18 +1,58 @@ -import { StoreDTO, FilterableStoreProps, UpdateStoreDTO } from "@medusajs/types" -import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { StoreDTO, StoreWorkflow } from "@medusajs/types" +import { + WorkflowData, + createWorkflow, + transform, + when, +} from "@medusajs/workflows-sdk" import { updateStoresStep } from "../steps" +import { updatePricePreferencesAsArrayStep } from "../../pricing" -type UpdateStoresStepInput = { - selector: FilterableStoreProps - update: UpdateStoreDTO -} - -type WorkflowInput = UpdateStoresStepInput +type WorkflowInputData = StoreWorkflow.UpdateStoreWorkflowInput export const updateStoresWorkflowId = "update-stores" export const updateStoresWorkflow = createWorkflow( updateStoresWorkflowId, - (input: WorkflowData): WorkflowData => { - return updateStoresStep(input) + (input: WorkflowData): WorkflowData => { + 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 } ) diff --git a/packages/core/types/src/http/store/admin/payloads.ts b/packages/core/types/src/http/store/admin/payloads.ts index b5949b0e50..13460c0227 100644 --- a/packages/core/types/src/http/store/admin/payloads.ts +++ b/packages/core/types/src/http/store/admin/payloads.ts @@ -1,10 +1,11 @@ interface AdminUpdateStoreSupportedCurrency { + currency_code: string is_default?: boolean - code: string + is_tax_inclusive?: boolean } export interface AdminUpdateStore { - name?: string | null + name?: string supported_currencies?: AdminUpdateStoreSupportedCurrency[] default_sales_channel_id?: string | null default_region_id?: string | null diff --git a/packages/core/types/src/store/mutations/store.ts b/packages/core/types/src/store/mutations/store.ts index d978ee8504..b9f26be28e 100644 --- a/packages/core/types/src/store/mutations/store.ts +++ b/packages/core/types/src/store/mutations/store.ts @@ -71,20 +71,20 @@ export interface UpdateStoreDTO { /** * The associated default sales channel's ID. */ - default_sales_channel_id?: string + default_sales_channel_id?: string | null /** * The associated default region's ID. */ - default_region_id?: string + default_region_id?: string | null /** * The associated default location's ID. */ - default_location_id?: string + default_location_id?: string | null /** * Holds custom data in key-value pairs. */ - metadata?: Record + metadata?: Record | null } diff --git a/packages/core/types/src/workflow/index.ts b/packages/core/types/src/workflow/index.ts index 3f8a1f35e1..295c1709bd 100644 --- a/packages/core/types/src/workflow/index.ts +++ b/packages/core/types/src/workflow/index.ts @@ -11,3 +11,4 @@ export * as ReservationWorkflow from "./reservation" export * as UserWorkflow from "./user" export * as OrderWorkflow from "./order" export * as PricingWorkflow from "./pricing" +export * as StoreWorkflow from "./store" diff --git a/packages/core/types/src/workflow/store/index.ts b/packages/core/types/src/workflow/store/index.ts new file mode 100644 index 0000000000..509696833e --- /dev/null +++ b/packages/core/types/src/workflow/store/index.ts @@ -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 +} diff --git a/packages/medusa/src/api/admin/stores/[id]/route.ts b/packages/medusa/src/api/admin/stores/[id]/route.ts index 78285eae7f..cd40c95990 100644 --- a/packages/medusa/src/api/admin/stores/[id]/route.ts +++ b/packages/medusa/src/api/admin/stores/[id]/route.ts @@ -1,5 +1,4 @@ import { updateStoresWorkflow } from "@medusajs/core-flows" -import { UpdateStoreDTO } from "@medusajs/types" import { remoteQueryObjectFromString, ContainerRegistrationKeys, @@ -35,7 +34,7 @@ export const POST = async ( const { result } = await updateStoresWorkflow(req.scope).run({ input: { selector: { id: req.params.id }, - update: req.validatedBody as UpdateStoreDTO, + update: req.validatedBody, }, }) diff --git a/packages/medusa/src/api/admin/stores/validators.ts b/packages/medusa/src/api/admin/stores/validators.ts index 3e14142b96..daf421f3ef 100644 --- a/packages/medusa/src/api/admin/stores/validators.ts +++ b/packages/medusa/src/api/admin/stores/validators.ts @@ -20,12 +20,13 @@ export const AdminGetStoresParams = createFindParams({ export type AdminUpdateStoreType = z.infer export const AdminUpdateStore = z.object({ - name: z.string().nullish(), + name: z.string().optional(), supported_currencies: z .array( z.object({ currency_code: z.string(), is_default: z.boolean().optional(), + is_tax_inclusive: z.boolean().optional(), }) ) .optional(), diff --git a/packages/modules/store/src/services/store-module-service.ts b/packages/modules/store/src/services/store-module-service.ts index 99f0828e2d..a1c68c19d7 100644 --- a/packages/modules/store/src/services/store-module-service.ts +++ b/packages/modules/store/src/services/store-module-service.ts @@ -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) { if (store.supported_currencies?.length) { const duplicates = getDuplicates(