Add support for tax inclusivity to region and store (#7808)

This also includes rework of the currency model for the Store module.

This change is breaking as existing stores won't have any supported currencies set, so users would need to go to the store settings again and choose the supported currencies there.
This commit is contained in:
Stevche Radevski
2024-06-24 17:25:44 +02:00
committed by GitHub
parent 79d90fadc4
commit e8d6025374
45 changed files with 580 additions and 408 deletions

View File

@@ -29,8 +29,9 @@ export const seedStorefrontDefaults = async (
store = await storeModule.updateStores(store.id, {
default_region_id: region.id,
supported_currency_codes: [region.currency_code],
default_currency_code: region.currency_code,
supported_currencies: [
{ currency_code: region.currency_code, is_default: true },
],
})
return {

View File

@@ -97,8 +97,10 @@ medusaIntegrationTestRunner({
store = await storeModule.createStores({
name: "New store",
supported_currency_codes: ["usd", "dkk"],
default_currency_code: "usd",
supported_currencies: [
{ currency_code: "usd", is_default: true },
{ currency_code: "dkk" },
],
})
})
@@ -570,8 +572,10 @@ medusaIntegrationTestRunner({
}
await service.createStores({
supported_currency_codes: ["usd", "dkk"],
default_currency_code: "usd",
supported_currencies: [
{ currency_code: "usd", is_default: true },
{ currency_code: "dkk" },
],
default_sales_channel_id: defaultSalesChannel.id,
})
})
@@ -1139,8 +1143,10 @@ medusaIntegrationTestRunner({
}
await service.createStores({
supported_currency_codes: ["usd", "dkk"],
default_currency_code: "usd",
supported_currencies: [
{ currency_code: "usd", is_default: true },
{ currency_code: "dkk" },
],
default_sales_channel_id: defaultSalesChannel.id,
})
})

View File

@@ -30,8 +30,10 @@ medusaIntegrationTestRunner({
store = await storeModule.createStores({
name: "New store",
supported_currency_codes: ["usd", "dkk"],
default_currency_code: "usd",
supported_currencies: [
{ currency_code: "usd", is_default: true },
{ currency_code: "dkk" },
],
default_sales_channel_id: "sc_12345",
})
})
@@ -48,9 +50,14 @@ medusaIntegrationTestRunner({
expect.objectContaining({
id: expect.any(String),
name: "New store",
default_currency_code: "usd",
default_sales_channel_id: expect.any(String),
supported_currency_codes: ["usd", "dkk"],
supported_currencies: [
expect.objectContaining({
currency_code: "usd",
is_default: true,
}),
expect.objectContaining({ currency_code: "dkk" }),
],
created_at: expect.any(String),
updated_at: expect.any(String),
})
@@ -59,12 +66,12 @@ medusaIntegrationTestRunner({
})
describe("POST /admin/stores", () => {
it("fails to update default currency if not in store currencies", async () => {
it("fails to update default currencies if there is no default one", async () => {
const err = await api
.post(
`/admin/stores/${store.id}`,
{
default_currency_code: "eur",
supported_currencies: [{ currency_code: "eur" }],
},
adminHeaders
)
@@ -74,17 +81,19 @@ medusaIntegrationTestRunner({
expect(err.response.data).toEqual(
expect.objectContaining({
type: "invalid_data",
message: "Store does not have currency: eur",
message: "There should be a default currency set for the store",
})
)
})
// BREAKING: `currencies` was renamed to `supported_currency_codes`
// BREAKING: `currencies` was renamed to `supported_currencies`
it("fails to remove default currency from currencies without replacing it", async () => {
const err = await api
.post(
`/admin/stores/${store.id}`,
{ supported_currency_codes: ["dkk"] },
{
supported_currencies: [{ currency_code: "dkk" }],
},
adminHeaders
)
.catch((e) => e)
@@ -93,41 +102,19 @@ medusaIntegrationTestRunner({
expect(err.response.data).toEqual(
expect.objectContaining({
type: "invalid_data",
message:
"You are not allowed to remove default currency from store currencies without replacing it as well",
message: "There should be a default currency set for the store",
})
)
})
it("successfully updates default currency code", async () => {
const response = await api
.post(
`/admin/stores/${store.id}`,
{
default_currency_code: "dkk",
},
adminHeaders
)
.catch((err) => console.log(err))
expect(response.status).toEqual(200)
expect(response.data.store).toEqual(
expect.objectContaining({
id: expect.any(String),
name: "New store",
default_currency_code: "dkk",
created_at: expect.any(String),
updated_at: expect.any(String),
})
)
})
it("successfully updates default currency and store currencies", async () => {
it("successfully updates currencies and default currency", async () => {
const response = await api.post(
`/admin/stores/${store.id}`,
{
default_currency_code: "jpy",
supported_currency_codes: ["jpy", "usd"],
supported_currencies: [
{ currency_code: "usd" },
{ currency_code: "dkk", is_default: true },
],
},
adminHeaders
)
@@ -137,9 +124,15 @@ medusaIntegrationTestRunner({
expect.objectContaining({
id: expect.any(String),
name: "New store",
default_sales_channel_id: expect.any(String),
supported_currency_codes: ["jpy", "usd"],
default_currency_code: "jpy",
supported_currencies: [
expect.objectContaining({
currency_code: "usd",
}),
expect.objectContaining({
currency_code: "dkk",
is_default: true,
}),
],
created_at: expect.any(String),
updated_at: expect.any(String),
})
@@ -150,7 +143,10 @@ medusaIntegrationTestRunner({
const response = await api.post(
`/admin/stores/${store.id}`,
{
supported_currency_codes: ["jpy", "usd"],
supported_currencies: [
{ currency_code: "jpy", is_default: true },
{ currency_code: "usd" },
],
},
adminHeaders
)
@@ -161,8 +157,15 @@ medusaIntegrationTestRunner({
id: expect.any(String),
default_sales_channel_id: expect.any(String),
name: "New store",
supported_currency_codes: ["jpy", "usd"],
default_currency_code: "usd",
supported_currencies: [
expect.objectContaining({
currency_code: "jpy",
is_default: true,
}),
expect.objectContaining({
currency_code: "usd",
}),
],
created_at: expect.any(String),
updated_at: expect.any(String),
})

View File

@@ -1,5 +1,6 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { ICurrencyModuleService, IStoreModuleService } from "@medusajs/types"
import { remoteQueryObjectFromString } from "@medusajs/utils"
import { medusaIntegrationTestRunner } from "medusa-test-utils"
jest.setTimeout(50000)
@@ -27,24 +28,29 @@ medusaIntegrationTestRunner({
it("should query store and default currency with remote query", async () => {
const store = await storeModuleService.createStores({
name: "Store",
default_currency_code: "usd",
supported_currency_codes: ["usd"],
supported_currencies: [{ currency_code: "usd", is_default: true }],
})
const stores = await remoteQuery({
store: {
fields: ["id"],
default_currency: {
fields: ["code"],
},
},
const query = remoteQueryObjectFromString({
entryPoint: "store",
fields: [
"id",
"supported_currencies.*",
"supported_currencies.currency.*",
],
})
const stores = await remoteQuery(query)
expect(stores).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: store.id,
default_currency: expect.objectContaining({ code: "usd" }),
supported_currencies: expect.arrayContaining([
expect.objectContaining({
currency: expect.objectContaining({ code: "usd" }),
currency_code: "usd",
}),
]),
}),
])
)

View File

@@ -29,13 +29,18 @@ medusaIntegrationTestRunner({
it("should correctly implement the entire lifecycle of a store", async () => {
const createdStore = await service.createStores({
name: "Test store",
supported_currency_codes: ["usd"],
supported_currencies: [{ currency_code: "usd", is_default: true }],
})
expect(createdStore).toEqual(
expect.objectContaining({
id: createdStore.id,
supported_currency_codes: ["usd"],
supported_currencies: [
expect.objectContaining({
currency_code: "usd",
is_default: true,
}),
],
name: "Test store",
})
)

View File

@@ -848,7 +848,6 @@
"shippingProfilesDesc": "Shipping rules for different types of products",
"shippingOptionTypes": "Shipping Option Types",
"shippingOptionTypesDesc": "Group options based on characteristic"
},
"salesChannels": {
"header": "Sales Channels",
@@ -1422,6 +1421,7 @@
"removeCountriesWarning_one": "You are about to remove {{count}} country from the region. This action cannot be undone.",
"removeCountriesWarning_other": "You are about to remove {{count}} countries from the region. This action cannot be undone.",
"removeCountryWarning": "You are about to remove the country {{name}} from the region. This action cannot be undone.",
"automaticTaxesHint": "When enabled taxes will only be calculated at checkout based on the shipping address.",
"taxInclusiveHint": "When enabled prices in the region will be tax inclusive.",
"providersHint": " Add which payment providers should be available in this region.",
"shippingOptions": "Shipping Options",
@@ -1791,6 +1791,7 @@
"inventory": "Inventory",
"optional": "Optional",
"note": "Note",
"automaticTaxes": "Automatic Taxes",
"taxInclusivePricing": "Tax inclusive pricing",
"taxRate": "Tax Rate",
"taxCode": "Tax Code",

View File

@@ -248,10 +248,13 @@ export const CreateCampaignFormFields = ({ form, fieldScope = "" }) => {
<Select.Content>
{Object.values(currencies)
.filter((currency) =>
store?.supported_currency_codes?.includes(
currency.code.toLocaleLowerCase()
)
.filter(
(currency) =>
!!store?.supported_currencies?.find(
(c) =>
c.currency_code ===
currency.code.toLocaleLowerCase()
)
)
.map((currency) => (
<Select.Item

View File

@@ -22,7 +22,7 @@ export const CreateShippingOptionsPricesForm = ({
} = useStore()
const currencies = useMemo(
() => store?.supported_currency_codes || [],
() => store?.supported_currencies?.map((c) => c.currency_code) || [],
[store]
)

View File

@@ -94,7 +94,7 @@ export function EditShippingOptionsPricingForm({
} = useStore()
const currencies = useMemo(
() => store?.supported_currency_codes || [],
() => store?.supported_currencies?.map((c) => c.currency_code) || [],
[store]
)

View File

@@ -28,8 +28,8 @@ export const PricingPricesForm = ({ form }: PricingPricesFormProps) => {
error: currencyError,
} = useCurrencies(
{
code: store?.supported_currency_codes,
limit: store?.supported_currency_codes?.length,
code: store?.supported_currencies?.map((c) => c.currency_code),
limit: store?.supported_currencies?.length,
},
{
enabled: !!store,

View File

@@ -97,7 +97,7 @@ export const PricingProductPricesForm = ({
error: currencyError,
} = useCurrencies(
{
code: store?.supported_currency_codes,
code: store?.supported_currencies?.map((c) => c.currency_code),
},
{
enabled: !!store,

View File

@@ -19,8 +19,8 @@ export const VariantPricingForm = ({ form }: VariantPricingFormProps) => {
const { store, isLoading: isStoreLoading } = useStore()
const { currencies, isLoading: isCurrenciesLoading } = useCurrencies(
{
code: store?.supported_currency_codes,
limit: store?.supported_currency_codes?.length,
code: store?.supported_currencies?.map((c) => c.currency_code),
limit: store?.supported_currencies?.length,
},
{
enabled: !!store,

View File

@@ -21,9 +21,7 @@ export const ProductCreateVariantsForm = ({
}: ProductCreateVariantsFormProps) => {
const { regions } = useRegions({ limit: 9999 })
const { store, isPending, isError, error } = useStore({
fields: "supported_currency_codes",
})
const { store, isPending, isError, error } = useStore()
const variants = useWatch({
control: form.control,
@@ -39,7 +37,7 @@ export const ProductCreateVariantsForm = ({
const columns = useColumns({
options,
currencies: store?.supported_currency_codes,
currencies: store?.supported_currencies?.map((c) => c.currency_code) || [],
regions,
})

View File

@@ -28,7 +28,7 @@ const buildFilters = (attribute?: string, store?: StoreDTO) => {
if (attribute === "currency_code") {
return {
value: store.supported_currency_codes,
value: store.supported_currencies?.map((c) => c.currency_code),
}
}

View File

@@ -44,7 +44,7 @@ type CreateRegionFormProps = {
const CreateRegionSchema = zod.object({
name: zod.string().min(1),
currency_code: zod.string().min(2, "Select a currency"),
includes_tax: zod.boolean(),
automatic_taxes: zod.boolean(),
countries: zod.array(zod.object({ code: zod.string(), name: zod.string() })),
payment_providers: zod.array(zod.string()).min(1),
})
@@ -64,7 +64,7 @@ export const CreateRegionForm = ({
defaultValues: {
name: "",
currency_code: "",
includes_tax: false,
automatic_taxes: true,
countries: [],
payment_providers: [],
},
@@ -88,7 +88,7 @@ export const CreateRegionForm = ({
countries: values.countries.map((c) => c.code),
currency_code: values.currency_code,
payment_providers: values.payment_providers,
automatic_taxes: values.includes_tax,
automatic_taxes: values.automatic_taxes,
},
{
onSuccess: ({ region }) => {
@@ -277,14 +277,14 @@ export const CreateRegionForm = ({
</div>
<Form.Field
control={form.control}
name="includes_tax"
name="automatic_taxes"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div>
<div className="flex items-start justify-between">
<Form.Label>
{t("fields.taxInclusivePricing")}
{t("fields.automaticTaxes")}
</Form.Label>
<Form.Control>
<Switch
@@ -295,7 +295,7 @@ export const CreateRegionForm = ({
</Form.Control>
</div>
<Form.Hint>
{t("regions.taxInclusiveHint")}
{t("regions.automaticTaxesHint")}
</Form.Hint>
<Form.ErrorMessage />
</div>
@@ -303,6 +303,7 @@ export const CreateRegionForm = ({
)
}}
/>
<div className="bg-ui-border-base h-px w-full" />
<div className="flex flex-col gap-y-4">
<div>

View File

@@ -7,8 +7,8 @@ import { useStore } from "../../../hooks/api/store"
export const RegionCreate = () => {
const { store, isPending: isLoading, isError, error } = useStore()
const storeCurrencies = (store?.supported_currency_codes ?? []).map(
(code) => currencies[code.toUpperCase()]
const storeCurrencies = (store?.supported_currencies ?? []).map(
(c) => currencies[c.currency_code.toUpperCase()]
)
const { payment_providers: paymentProviders = [] } = usePaymentProviders()

View File

@@ -29,8 +29,8 @@ export const RegionEdit = () => {
const isLoading = isRegionLoading || isStoreLoading
const storeCurrencies = (store?.supported_currency_codes ?? []).map(
(code) => currencies[code.toUpperCase()]
const storeCurrencies = (store?.supported_currencies ?? []).map(
(c) => currencies[c.currency_code.toUpperCase()]
)
const { payment_providers: paymentProviders = [] } = usePaymentProviders({
limit: 999,

View File

@@ -78,7 +78,8 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => {
placeholderData: keepPreviousData,
})
const preSelectedRows = store.supported_currency_codes.map((c) => c)
const preSelectedRows =
store.supported_currencies?.map((c) => c.currency_code) ?? []
const columns = useColumns()
@@ -104,9 +105,20 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => {
new Set([...data.currencies, ...preSelectedRows])
) as string[]
let defaultCurrency = store.supported_currencies?.find(
(c) => c.is_default
)?.currency_code
if (!currencies.includes(defaultCurrency ?? "")) {
defaultCurrency = currencies?.[0]
}
try {
await mutateAsync({
supported_currency_codes: currencies,
supported_currencies: currencies.map((c) => ({
currency_code: c,
is_default: c === defaultCurrency,
})),
})
toast.success(t("general.success"), {
description: t("store.toast.currenciesUpdated"),

View File

@@ -1,5 +1,5 @@
import { Plus, Trash } from "@medusajs/icons"
import { CurrencyDTO } from "@medusajs/types"
import { CurrencyDTO, StoreCurrencyDTO } from "@medusajs/types"
import {
Checkbox,
CommandBar,
@@ -40,7 +40,7 @@ export const StoreCurrencySection = ({ store }: StoreCurrencySectionProps) => {
error,
} = useCurrencies(
{
code: store.supported_currency_codes,
code: store.supported_currencies?.map((c) => c.currency_code),
...searchParams,
},
{
@@ -64,8 +64,9 @@ export const StoreCurrencySection = ({ store }: StoreCurrencySectionProps) => {
pageSize: PAGE_SIZE,
meta: {
storeId: store.id,
currencyCodes: store.supported_currency_codes,
defaultCurrencyCode: store.default_currency_code,
supportedCurrencies: store.supported_currencies,
defaultCurrencyCode: store.supported_currencies?.find((c) => c.is_default)
?.currency_code,
},
})
@@ -91,9 +92,10 @@ export const StoreCurrencySection = ({ store }: StoreCurrencySectionProps) => {
try {
await mutateAsync({
supported_currency_codes: store.supported_currency_codes.filter(
(c) => !ids.includes(c)
),
supported_currencies:
store.supported_currencies?.filter(
(c) => !ids.includes(c.currency_code)
) ?? [],
})
setRowSelection({})
@@ -164,12 +166,12 @@ export const StoreCurrencySection = ({ store }: StoreCurrencySectionProps) => {
const CurrencyActions = ({
storeId,
currency,
currencyCodes,
supportedCurrencies,
defaultCurrencyCode,
}: {
storeId: string
currency: CurrencyDTO
currencyCodes: string[]
supportedCurrencies: StoreCurrencyDTO[]
defaultCurrencyCode: string
}) => {
const { mutateAsync } = useUpdateStore(storeId)
@@ -195,8 +197,8 @@ const CurrencyActions = ({
try {
await mutateAsync({
supported_currency_codes: currencyCodes.filter(
(c) => c !== currency.code
supported_currencies: supportedCurrencies.filter(
(c) => c.currency_code !== currency.code
),
})
@@ -269,9 +271,9 @@ const useColumns = () => {
columnHelper.display({
id: "actions",
cell: ({ row, table }) => {
const { currencyCodes, storeId, defaultCurrencyCode } = table.options
.meta as {
currencyCodes: string[]
const { supportedCurrencies, storeId, defaultCurrencyCode } = table
.options.meta as {
supportedCurrencies: StoreCurrencyDTO[]
storeId: string
defaultCurrencyCode: string
}
@@ -280,7 +282,7 @@ const useColumns = () => {
<CurrencyActions
storeId={storeId}
currency={row.original}
currencyCodes={currencyCodes}
supportedCurrencies={supportedCurrencies}
defaultCurrencyCode={defaultCurrencyCode}
/>
)

View File

@@ -16,6 +16,8 @@ export const StoreGeneralSection = ({ store }: StoreGeneralSectionProps) => {
enabled: !!store.default_region_id,
})
const defaultCurrency = store.supported_currencies?.find((c) => c.is_default)
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
@@ -51,13 +53,13 @@ export const StoreGeneralSection = ({ store }: StoreGeneralSectionProps) => {
<Text size="small" leading="compact" weight="plus">
{t("store.defaultCurrency")}
</Text>
{store.default_currency ? (
{defaultCurrency ? (
<div className="flex items-center gap-x-2">
<Badge size="2xsmall">
{store.default_currency.code.toUpperCase()}
{defaultCurrency.currency_code.toUpperCase()}
</Badge>
<Text size="small" leading="compact">
{store.default_currency.name}
{defaultCurrency.currency.name}
</Text>
</div>
) : (

View File

@@ -32,7 +32,9 @@ export const EditStoreForm = ({ store }: EditStoreFormProps) => {
defaultValues: {
name: store.name,
default_region_id: store.default_region_id || undefined,
default_currency_code: store.default_currency_code || undefined,
default_currency_code:
store.supported_currencies?.find((c) => c.is_default)?.currency_code ||
undefined,
},
resolver: zodResolver(EditStoreSchema),
})
@@ -43,7 +45,15 @@ export const EditStoreForm = ({ store }: EditStoreFormProps) => {
const handleSubmit = form.handleSubmit(async (values) => {
try {
await mutateAsync(values)
const normalizedMutation = {
...values,
default_currency_code: undefined,
supported_currencies: store.supported_currencies?.map((c) => ({
...c,
is_default: c.currency_code === values.default_currency_code,
})),
}
await mutateAsync(normalizedMutation)
handleSuccess()
@@ -91,9 +101,12 @@ export const EditStoreForm = ({ store }: EditStoreFormProps) => {
<Select.Value />
</Select.Trigger>
<Select.Content>
{store.supported_currency_codes.map((code) => (
<Select.Item key={code} value={code}>
{code.toUpperCase()}
{store.supported_currencies?.map((currency) => (
<Select.Item
key={currency.currency_code}
value={currency.currency_code}
>
{currency.currency_code.toUpperCase()}
</Select.Item>
))}
</Select.Content>

View File

@@ -22,7 +22,6 @@ import {
StockLocationDTO,
StoreDTO,
UserDTO,
HttpTypes,
} from "@medusajs/types"
import { WorkflowExecutionDTO } from "../routes/workflow-executions/types"
@@ -57,9 +56,7 @@ export type UserListRes = { users: UserDTO[] } & ListRes
export type UserDeleteRes = DeleteRes
// Stores
export type ExtendedStoreDTO = StoreDTO & {
default_currency: CurrencyDTO | null
}
export type ExtendedStoreDTO = StoreDTO
export type StoreRes = { store: ExtendedStoreDTO }
export type StoreListRes = { stores: ExtendedStoreDTO[] } & ListRes

View File

@@ -28,8 +28,9 @@ export const createDefaultStoreStep = createStep(
{
// TODO: Revisit for a more sophisticated approach
...data.store,
supported_currency_codes: ["eur"],
default_currency_code: "eur",
supported_currencies: [
{ currency_code: "eur", is_default: true },
],
},
],
},

View File

@@ -20,7 +20,7 @@ export * as OrderTypes from "./order"
export * as PricingTypes from "./pricing"
export * as ProductTypes from "./product"
export * as PromotionTypes from "./promotion"
export * as RegionTypes from "./region__legacy"
export * as RegionTypes from "./region"
export * as SalesChannelTypes from "./sales-channel"
export * as SearchTypes from "./search"
export * as StockLocationTypes from "./stock-location"

View File

@@ -28,7 +28,6 @@ export * from "./product"
export * from "./product-category"
export * from "./promotion"
export * from "./region"
export * from "./region__legacy"
export * from "./sales-channel"
export * from "./search"
export * from "./shared-context"

View File

@@ -24,7 +24,6 @@ export interface RegionDTO {
* Setting to indicate whether taxes need to be applied automatically
*/
automatic_taxes: boolean
/**
* The countries of the region.
*/

View File

@@ -1,51 +0,0 @@
/**
* @interface
*
* The details of a legacy region.
*/
export type RegionDTO__legacy = {
/**
* The name of the region.
*/
name: string
/**
* The currency code of the region.
*/
currency_code: string
/**
* The tax rate of the region.
*/
tax_rate?: number
/**
* The tax code of the region.
*/
tax_code?: string | null
/**
* Whether gift cards in the region are taxable.
*/
gift_cards_taxable?: boolean
/**
* Whether taxes should be calculated automatically in the region.
*/
automatic_taxes?: boolean
/**
* The associated tax provider's ID.
*/
tax_provider_id?: string | null
/**
* Holds custom data in key-value pairs.
*/
metadata?: Record<string, unknown>
/**
* Whether prices include taxes in the region.
*/
includes_tax?: boolean
}

View File

@@ -1 +0,0 @@
export * from "./common"

View File

@@ -1,5 +1,36 @@
import { BaseFilterable } from "../../dal"
export interface StoreCurrencyDTO {
/**
* The ID of the store currency.
*/
id: string
/**
* The currency code of the store currency.
*/
currency_code: string
/**
* Whether the currency is the default one for the store.
*/
is_default: boolean
/**
* The store ID associated with the currency.
*/
store_id: string
/**
* The created date of the currency
*/
created_at: string
/**
* The updated date of the currency
*/
updated_at: string
/**
* The deleted date of the currency
*/
deleted_at: string | null
}
/**
* The store details.
*/
@@ -17,12 +48,7 @@ export interface StoreDTO {
/**
* The supported currency codes of the store.
*/
supported_currency_codes: string[]
/**
* The default currency code of the store.
*/
default_currency_code?: string
supported_currencies?: StoreCurrencyDTO[]
/**
* The associated default sales channel's ID.

View File

@@ -1,3 +1,14 @@
export interface CreateStoreCurrencyDTO {
/**
* The currency code of the store currency.
*/
currency_code: string
/**
* Whether the currency is the default one for the store.
*/
is_default?: boolean
}
/**
* The store to be created.
*/
@@ -10,12 +21,7 @@ export interface CreateStoreDTO {
/**
* The supported currency codes of the store.
*/
supported_currency_codes?: string[]
/**
* The default currency code of the store.
*/
default_currency_code?: string
supported_currencies?: CreateStoreCurrencyDTO[]
/**
* The associated default sales channel's ID.
@@ -41,46 +47,11 @@ export interface CreateStoreDTO {
/**
* The attributes in the store to be created or updated.
*/
export interface UpsertStoreDTO {
export interface UpsertStoreDTO extends UpdateStoreDTO {
/**
* The ID of the store.
* The ID of the store when doing an update.
*/
id?: string
/**
* The name of the store.
*/
name?: string
/**
* The supported currency codes of the store.
*/
supported_currency_codes?: string[]
/**
* The default currency code of the store.
*/
default_currency_code?: string
/**
* The associated default sales channel's ID.
*/
default_sales_channel_id?: string
/**
* The associated default region's ID.
*/
default_region_id?: string
/**
* The associated default location's ID.
*/
default_location_id?: string
/**
* Holds custom data in key-value pairs.
*/
metadata?: Record<string, any>
}
/**
@@ -95,12 +66,7 @@ export interface UpdateStoreDTO {
/**
* The supported currency codes of the store.
*/
supported_currency_codes?: string[]
/**
* The default currency code of the store.
*/
default_currency_code?: string
supported_currencies?: CreateStoreCurrencyDTO[]
/**
* The associated default sales channel's ID.

View File

@@ -20,13 +20,11 @@ export interface IStoreModuleService extends IModuleService {
* const stores = await storeModuleService.createStores([
* {
* name: "Acme",
* supported_currency_codes: ["usd", "eur"],
* default_currency_code: "usd",
* supported_currencies: [{ currency_code: "usd", is_default: true }, { currency_code: "eur" }],
* },
* {
* name: "Acme 2",
* supported_currency_codes: ["usd"],
* default_currency_code: "usd",
* supported_currencies: [{currency_code: "usd", is_default: true}],
* },
* ])
*/
@@ -45,8 +43,7 @@ export interface IStoreModuleService extends IModuleService {
* @example
* const store = await storeModuleService.createStores({
* name: "Acme",
* supported_currency_codes: ["usd", "eur"],
* default_currency_code: "usd",
* supported_currencies: [{ currency_code: "usd", is_default: true }, { currency_code: "eur" }],
* })
*/
createStores(data: CreateStoreDTO, sharedContext?: Context): Promise<StoreDTO>
@@ -66,8 +63,7 @@ export interface IStoreModuleService extends IModuleService {
* },
* {
* name: "Acme 2",
* supported_currency_codes: ["usd"],
* default_currency_code: "usd",
* supported_currencies: [{currency_code: "usd", is_default: true}],
* },
* ])
*/
@@ -124,7 +120,6 @@ export interface IStoreModuleService extends IModuleService {
* name: ["Acme"],
* },
* {
* default_currency_code: "usd",
* }
* )
*/

View File

@@ -1,11 +1,8 @@
export const defaultAdminStoreFields = [
"id",
"name",
"supported_currency_codes",
"default_currency_code",
"default_currency.name",
"default_currency.symbol",
"default_currency.symbol_native",
"*supported_currencies",
"*supported_currencies.currency",
"default_sales_channel_id",
"default_region_id",
"default_location_id",

View File

@@ -21,8 +21,14 @@ export const AdminGetStoresParams = createFindParams({
export type AdminUpdateStoreType = z.infer<typeof AdminUpdateStore>
export const AdminUpdateStore = z.object({
name: z.string().nullish(),
supported_currency_codes: z.array(z.string()).optional(),
default_currency_code: z.string().nullish(),
supported_currencies: z
.array(
z.object({
currency_code: z.string(),
is_default: z.boolean().optional(),
})
)
.optional(),
default_sales_channel_id: z.string().nullish(),
default_region_id: z.string().nullish(),
default_location_id: z.string().nullish(),

View File

@@ -8,4 +8,4 @@ export * from "./order-customer"
export * from "./order-product"
export * from "./order-region"
export * from "./order-sales-channel"
export * from "./store-default-currency"
export * from "./store-currency"

View File

@@ -1,7 +1,7 @@
import { ModuleJoinerConfig } from "@medusajs/types"
import { Modules } from "@medusajs/utils"
export const StoreDefaultCurrency: ModuleJoinerConfig = {
export const StoreCurrencies: ModuleJoinerConfig = {
isLink: true,
isReadOnlyLink: true,
extends: [
@@ -10,8 +10,8 @@ export const StoreDefaultCurrency: ModuleJoinerConfig = {
relationship: {
serviceName: Modules.CURRENCY,
primaryKey: "code",
foreignKey: "default_currency_code",
alias: "default_currency",
foreignKey: "supported_currencies.currency_code",
alias: "currency",
args: {
methodSuffix: "Currencies",
},

View File

@@ -1,7 +1,5 @@
{
"namespaces": [
"public"
],
"namespaces": ["public"],
"name": "public",
"tables": [
{
@@ -33,6 +31,16 @@
"nullable": false,
"mappedType": "text"
},
"automatic_taxes": {
"name": "automatic_taxes",
"type": "boolean",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "true",
"mappedType": "boolean"
},
"metadata": {
"name": "metadata",
"type": "jsonb",
@@ -79,9 +87,7 @@
"schema": "public",
"indexes": [
{
"columnNames": [
"deleted_at"
],
"columnNames": ["deleted_at"],
"composite": false,
"keyName": "IDX_region_deleted_at",
"primary": false,
@@ -89,9 +95,7 @@
},
{
"keyName": "region_pkey",
"columnNames": [
"id"
],
"columnNames": ["id"],
"composite": false,
"primary": true,
"unique": true
@@ -102,15 +106,6 @@
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"iso_2": {
"name": "iso_2",
"type": "text",
@@ -171,9 +166,7 @@
"indexes": [
{
"keyName": "region_country_pkey",
"columnNames": [
"id"
],
"columnNames": ["iso_2"],
"composite": false,
"primary": true,
"unique": true
@@ -183,13 +176,9 @@
"foreignKeys": {
"region_country_region_id_foreign": {
"constraintName": "region_country_region_id_foreign",
"columnNames": [
"region_id"
],
"columnNames": ["region_id"],
"localTableName": "public.region_country",
"referencedColumnNames": [
"id"
],
"referencedColumnNames": ["id"],
"referencedTableName": "public.region",
"deleteRule": "set null",
"updateRule": "cascade"

View File

@@ -2,7 +2,10 @@ import { StoreTypes } from "@medusajs/types"
export const createStoreFixture: StoreTypes.CreateStoreDTO = {
name: "Test store",
supported_currency_codes: ["usd", "eur"],
supported_currencies: [
{ currency_code: "usd" },
{ currency_code: "eur", is_default: true },
],
default_sales_channel_id: "test-sales-channel",
default_region_id: "test-region",
metadata: {

View File

@@ -16,7 +16,10 @@ moduleIntegrationTestRunner<IStoreModuleService>({
expect(store).toEqual(
expect.objectContaining({
name: "Test store",
supported_currency_codes: expect.arrayContaining(["eur", "usd"]),
supported_currencies: expect.arrayContaining([
expect.objectContaining({ currency_code: "eur" }),
expect.objectContaining({ currency_code: "usd" }),
]),
default_sales_channel_id: "test-sales-channel",
default_region_id: "test-region",
metadata: {
@@ -25,14 +28,19 @@ moduleIntegrationTestRunner<IStoreModuleService>({
})
)
})
})
it("should fail to get created if default currency code is not in list of supported currency codes", async function () {
const err = await service
.createStores({ ...createStoreFixture, default_currency_code: "jpy" })
.catch((err) => err.message)
it("should fail to get created if there is no default currency", async function () {
const err = await service
.createStores({
...createStoreFixture,
supported_currencies: [{ currency_code: "usd" }],
})
.catch((err) => err.message)
expect(err).toEqual("Store does not have currency: jpy")
expect(err).toEqual(
"There should be a default currency set for the store"
)
})
})
describe("upserting a store", () => {
@@ -75,44 +83,46 @@ moduleIntegrationTestRunner<IStoreModuleService>({
expect(updatedStore.name).toEqual("Updated store")
})
it("should fail updating default currency code to an unsupported one", async function () {
it("should fail updating currencies without a default one", async function () {
const createdStore = await service.createStores(createStoreFixture)
const updateErr = await service
.updateStores(createdStore.id, {
default_currency_code: "jpy",
})
.catch((err) => err.message)
expect(updateErr).toEqual("Store does not have currency: jpy")
})
it("should fail updating default currency code to an unsupported one if the supported currencies are also updated", async function () {
const createdStore = await service.createStores(createStoreFixture)
const updateErr = await service
.updateStores(createdStore.id, {
supported_currency_codes: ["mkd"],
default_currency_code: "jpy",
})
.catch((err) => err.message)
expect(updateErr).toEqual("Store does not have currency: jpy")
})
it("should fail updating supported currencies if one of them is used as a default one", async function () {
const createdStore = await service.createStores({
...createStoreFixture,
default_currency_code: "eur",
})
const updateErr = await service
.updateStores(createdStore.id, {
supported_currency_codes: ["jpy"],
supported_currencies: [{ currency_code: "usd" }],
})
.catch((err) => err.message)
expect(updateErr).toEqual(
"You are not allowed to remove default currency from store currencies without replacing it as well"
"There should be a default currency set for the store"
)
})
it("should fail updating currencies where a duplicate currency code exists", async function () {
const createdStore = await service.createStores(createStoreFixture)
const updateErr = await service
.updateStores(createdStore.id, {
supported_currencies: [
{ currency_code: "usd" },
{ currency_code: "usd" },
],
})
.catch((err) => err.message)
expect(updateErr).toEqual("Duplicate currency codes: usd")
})
it("should fail updating currencies where there is more than 1 default currency", async function () {
const createdStore = await service.createStores(createStoreFixture)
const updateErr = await service
.updateStores(createdStore.id, {
supported_currencies: [
{ currency_code: "usd", is_default: true },
{ currency_code: "eur", is_default: true },
],
})
.catch((err) => err.message)
expect(updateErr).toEqual("Only one default currency is allowed")
})
})
describe("deleting a store", () => {

View File

@@ -25,25 +25,6 @@
"default": "'Medusa Store'",
"mappedType": "text"
},
"supported_currency_codes": {
"name": "supported_currency_codes",
"type": "text[]",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "'{}'",
"mappedType": "array"
},
"default_currency_code": {
"name": "default_currency_code",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "text"
},
"default_sales_channel_id": {
"name": "default_sales_channel_id",
"type": "text",
@@ -138,6 +119,118 @@
],
"checks": [],
"foreignKeys": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"currency_code": {
"name": "currency_code",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"is_default": {
"name": "is_default",
"type": "boolean",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "false",
"mappedType": "boolean"
},
"store_id": {
"name": "store_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"mappedType": "text"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "store_currency",
"schema": "public",
"indexes": [
{
"keyName": "IDX_store_currency_deleted_at",
"columnNames": [
"deleted_at"
],
"composite": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_currency_deleted_at\" ON \"store_currency\" (deleted_at) WHERE deleted_at IS NOT NULL"
},
{
"keyName": "store_currency_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"store_currency_store_id_foreign": {
"constraintName": "store_currency_store_id_foreign",
"columnNames": [
"store_id"
],
"localTableName": "public.store_currency",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.store",
"deleteRule": "cascade",
"updateRule": "cascade"
}
}
}
]
}

View File

@@ -0,0 +1,21 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20240621145944 extends Migration {
async up(): Promise<void> {
this.addSql(
'create table if not exists "store_currency" ("id" text not null, "currency_code" text not null, "is_default" boolean not null default false, "store_id" text null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "store_currency_pkey" primary key ("id"));'
)
this.addSql(
'CREATE INDEX IF NOT EXISTS "IDX_store_currency_deleted_at" ON "store_currency" (deleted_at) WHERE deleted_at IS NOT NULL;'
)
this.addSql(
'alter table if exists "store_currency" add constraint "store_currency_store_id_foreign" foreign key ("store_id") references "store" ("id") on update cascade on delete cascade;'
)
this.addSql(
'alter table if exists "store" drop column if exists "supported_currency_codes";'
)
this.addSql(
'alter table if exists "store" drop column if exists "default_currency_code";'
)
}
}

View File

@@ -0,0 +1,81 @@
import {
DALUtils,
Searchable,
createPsqlIndexStatementHelper,
generateEntityId,
} from "@medusajs/utils"
import {
BeforeCreate,
Entity,
OnInit,
PrimaryKey,
Property,
Filter,
ManyToOne,
} from "@mikro-orm/core"
import Store from "./store"
const StoreCurrencyDeletedAtIndex = createPsqlIndexStatementHelper({
tableName: "store_currency",
columns: "deleted_at",
where: "deleted_at IS NOT NULL",
})
@Entity()
@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions)
export default class StoreCurrency {
@PrimaryKey({ columnType: "text" })
id: string
@Searchable()
@Property({ columnType: "text" })
currency_code: string
@Property({ columnType: "boolean", default: false })
is_default?: boolean
@ManyToOne(() => Store, {
columnType: "text",
fieldName: "store_id",
mapToPk: true,
nullable: true,
onDelete: "cascade",
})
store_id: string | null
@ManyToOne(() => Store, {
persist: false,
nullable: true,
})
store: Store | null
@Property({
onCreate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
})
created_at: Date
@Property({
onCreate: () => new Date(),
onUpdate: () => new Date(),
columnType: "timestamptz",
defaultRaw: "now()",
})
updated_at: Date
@StoreCurrencyDeletedAtIndex.MikroORMIndex()
@Property({ columnType: "timestamptz", nullable: true })
deleted_at: Date | null = null
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "stocur")
}
@OnInit()
onInit() {
this.id = generateEntityId(this.id, "stocur")
}
}

View File

@@ -1 +1,2 @@
export { default as Store } from "./store"
export { default as StoreCurrency } from "./currency"

View File

@@ -15,7 +15,11 @@ import {
Property,
Filter,
OptionalProps,
OneToMany,
Collection,
Cascade,
} from "@mikro-orm/core"
import StoreCurrency from "./currency"
type StoreOptionalProps = DAL.SoftDeletableEntityDateColumns
@@ -37,11 +41,10 @@ export default class Store {
@Property({ columnType: "text", default: "Medusa Store" })
name: string
@Property({ type: "array", default: "{}" })
supported_currency_codes: string[] = []
@Property({ columnType: "text", nullable: true })
default_currency_code: string | null = null
@OneToMany(() => StoreCurrency, (o) => o.store, {
cascade: [Cascade.PERSIST, "soft-remove"] as any,
})
supported_currencies = new Collection<StoreCurrency>(this)
@Property({ columnType: "text", nullable: true })
default_sales_channel_id: string | null = null

View File

@@ -8,6 +8,7 @@ import {
StoreTypes,
} from "@medusajs/types"
import {
getDuplicates,
InjectManager,
InjectTransactionManager,
isString,
@@ -18,7 +19,7 @@ import {
removeUndefined,
} from "@medusajs/utils"
import { Store } from "@models"
import { Store, StoreCurrency } from "@models"
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
import { UpdateStoreInput } from "@types"
@@ -30,7 +31,8 @@ type InjectedDependencies = {
export default class StoreModuleService
extends MedusaService<{
Store: { dto: StoreTypes.StoreDTO }
}>({ Store }, entityNameToLinkableKeysMap)
StoreCurrency: { dto: StoreTypes.StoreCurrencyDTO }
}>({ Store, StoreCurrency }, entityNameToLinkableKeysMap)
implements IStoreModuleService
{
protected baseRepository_: DAL.RepositoryService
@@ -81,7 +83,13 @@ export default class StoreModuleService
let normalizedInput = StoreModuleService.normalizeInput(data)
StoreModuleService.validateCreateRequest(normalizedInput)
return await this.storeService_.create(normalizedInput, sharedContext)
return (
await this.storeService_.upsertWithReplace(
normalizedInput,
{ relations: ["supported_currencies"] },
sharedContext
)
).entities
}
async upsertStores(
@@ -168,8 +176,15 @@ export default class StoreModuleService
@MedusaContext() sharedContext: Context = {}
): Promise<Store[]> {
const normalizedInput = StoreModuleService.normalizeInput(data)
await this.validateUpdateRequest(normalizedInput)
return await this.storeService_.update(normalizedInput, sharedContext)
StoreModuleService.validateUpdateRequest(normalizedInput)
return (
await this.storeService_.upsertWithReplace(
normalizedInput,
{ relations: ["supported_currencies"] },
sharedContext
)
).entities
}
private static normalizeInput<T extends StoreTypes.UpdateStoreDTO>(
@@ -178,6 +193,10 @@ export default class StoreModuleService
return stores.map((store) =>
removeUndefined({
...store,
supported_currencies: store.supported_currencies?.map((c) => ({
...c,
currency_code: c.currency_code.toLowerCase(),
})),
name: store.name?.trim(),
})
)
@@ -185,77 +204,42 @@ export default class StoreModuleService
private static validateCreateRequest(stores: StoreTypes.CreateStoreDTO[]) {
for (const store of stores) {
// If we are setting the default currency code on creating, make sure it is supported
if (store.default_currency_code) {
if (
!store.supported_currency_codes?.includes(
store.default_currency_code ?? ""
)
) {
if (store.supported_currencies?.length) {
const duplicates = getDuplicates(
store.supported_currencies?.map((c) => c.currency_code)
)
if (duplicates.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Store does not have currency: ${store.default_currency_code}`
`Duplicate currency codes: ${duplicates.join(", ")}`
)
}
let seenDefault = false
store.supported_currencies?.forEach((c) => {
if (c.is_default) {
if (seenDefault) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Only one default currency is allowed`
)
}
seenDefault = true
}
})
if (!seenDefault) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`There should be a default currency set for the store`
)
}
}
}
}
private async validateUpdateRequest(stores: UpdateStoreInput[]) {
const dbStores = await this.storeService_.list(
{ id: stores.map((s) => s.id) },
{ take: null }
)
const dbStoresMap = new Map<string, Store>(
dbStores.map((dbStore) => [dbStore.id, dbStore])
)
for (const store of stores) {
const dbStore = dbStoresMap.get(store.id)
// If it is updating both the supported currency codes and the default one, look in that list
if (store.supported_currency_codes && store.default_currency_code) {
if (
!store.supported_currency_codes.includes(
store.default_currency_code ?? ""
)
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Store does not have currency: ${store.default_currency_code}`
)
}
return
}
// If it is updating only the default currency code, look in the db store
if (store.default_currency_code) {
if (
!dbStore?.supported_currency_codes?.includes(
store.default_currency_code
)
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Store does not have currency: ${store.default_currency_code}`
)
}
}
// If it is updating only the supported currency codes, make sure one of them is not set as a default one
if (store.supported_currency_codes) {
if (
!store.supported_currency_codes.includes(
dbStore?.default_currency_code ?? ""
)
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"You are not allowed to remove default currency from store currencies without replacing it as well"
)
}
}
}
private static validateUpdateRequest(stores: UpdateStoreInput[]) {
StoreModuleService.validateCreateRequest(stores)
}
}