Skip to content

Commit fdc2b72

Browse files
authored
fix(core-flows, medusa): Prevent cart addresses duplication on update (medusajs#13841)
* Allow id field in addresses properties for cart update validator * Update cart addresses in update step where id is provided, both reference and nested fields * Add tests * Add changeset * Remove unnecessary map step * Review changes
1 parent 4dbf46f commit fdc2b72

File tree

4 files changed

+337
-5
lines changed

4 files changed

+337
-5
lines changed

.changeset/rare-crabs-cheer.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@medusajs/core-flows": patch
3+
"@medusajs/medusa": patch
4+
---
5+
6+
fix(medusa): Allow 'id' in shipping_address and billing_address in validator of store udpate cart route
7+
fix(core-flows): Update addresses nested fields when 'id' is provided in payload for update-carts step

integration-tests/modules/__tests__/cart/store/cart.workflows.spec.ts

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1444,6 +1444,290 @@ medusaIntegrationTestRunner({
14441444
)
14451445
expect(cart.items?.length).toEqual(1)
14461446
})
1447+
1448+
it("should update cart shipping address fields", async () => {
1449+
const salesChannel = await scModuleService.createSalesChannels({
1450+
name: "Webshop",
1451+
})
1452+
1453+
const regions = await regionModuleService.createRegions([
1454+
{
1455+
name: "US",
1456+
currency_code: "usd",
1457+
countries: ["us"],
1458+
},
1459+
])
1460+
1461+
let cart = await cartModuleService.createCarts({
1462+
currency_code: "usd",
1463+
sales_channel_id: salesChannel.id,
1464+
region_id: regions[0].id,
1465+
shipping_address: {
1466+
first_name: "John",
1467+
last_name: "Doe",
1468+
address_1: "123 Main St",
1469+
city: "New York",
1470+
country_code: "us",
1471+
postal_code: "10001",
1472+
},
1473+
})
1474+
1475+
const shippingAddressId = cart.shipping_address?.id
1476+
1477+
await updateCartWorkflow(appContainer).run({
1478+
input: {
1479+
id: cart.id,
1480+
shipping_address: {
1481+
id: shippingAddressId,
1482+
first_name: "Jane",
1483+
last_name: "Smith",
1484+
address_1: "456 Oak Ave",
1485+
city: "Los Angeles",
1486+
country_code: "us",
1487+
postal_code: "90001",
1488+
},
1489+
},
1490+
})
1491+
1492+
cart = await cartModuleService.retrieveCart(cart.id, {
1493+
relations: ["shipping_address"],
1494+
})
1495+
1496+
expect(cart.shipping_address).toEqual(
1497+
expect.objectContaining({
1498+
id: shippingAddressId,
1499+
first_name: "Jane",
1500+
last_name: "Smith",
1501+
address_1: "456 Oak Ave",
1502+
city: "Los Angeles",
1503+
country_code: "us",
1504+
postal_code: "90001",
1505+
})
1506+
)
1507+
})
1508+
1509+
it("should update cart billing address fields", async () => {
1510+
const salesChannel = await scModuleService.createSalesChannels({
1511+
name: "Webshop",
1512+
})
1513+
1514+
const regions = await regionModuleService.createRegions([
1515+
{
1516+
name: "US",
1517+
currency_code: "usd",
1518+
countries: ["us"],
1519+
},
1520+
])
1521+
1522+
let cart = await cartModuleService.createCarts({
1523+
currency_code: "usd",
1524+
sales_channel_id: salesChannel.id,
1525+
region_id: regions[0].id,
1526+
billing_address: {
1527+
first_name: "John",
1528+
last_name: "Doe",
1529+
address_1: "123 Main St",
1530+
city: "New York",
1531+
country_code: "us",
1532+
postal_code: "10001",
1533+
},
1534+
})
1535+
1536+
const billingAddressId = cart.billing_address?.id
1537+
1538+
await updateCartWorkflow(appContainer).run({
1539+
input: {
1540+
id: cart.id,
1541+
billing_address: {
1542+
id: billingAddressId,
1543+
first_name: "Jane",
1544+
last_name: "Smith",
1545+
address_1: "456 Oak Ave",
1546+
city: "Los Angeles",
1547+
country_code: "us",
1548+
postal_code: "90001",
1549+
},
1550+
},
1551+
})
1552+
1553+
cart = await cartModuleService.retrieveCart(cart.id, {
1554+
relations: ["billing_address"],
1555+
})
1556+
1557+
expect(cart.billing_address).toEqual(
1558+
expect.objectContaining({
1559+
id: billingAddressId,
1560+
first_name: "Jane",
1561+
last_name: "Smith",
1562+
address_1: "456 Oak Ave",
1563+
city: "Los Angeles",
1564+
country_code: "us",
1565+
postal_code: "90001",
1566+
})
1567+
)
1568+
})
1569+
1570+
it("should update both shipping and billing addresses simultaneously", async () => {
1571+
const salesChannel = await scModuleService.createSalesChannels({
1572+
name: "Webshop",
1573+
})
1574+
1575+
const regions = await regionModuleService.createRegions([
1576+
{
1577+
name: "US",
1578+
currency_code: "usd",
1579+
countries: ["us"],
1580+
},
1581+
])
1582+
1583+
let cart = await cartModuleService.createCarts({
1584+
currency_code: "usd",
1585+
sales_channel_id: salesChannel.id,
1586+
region_id: regions[0].id,
1587+
shipping_address: {
1588+
first_name: "John",
1589+
last_name: "Doe",
1590+
address_1: "123 Main St",
1591+
city: "New York",
1592+
country_code: "us",
1593+
postal_code: "10001",
1594+
},
1595+
billing_address: {
1596+
first_name: "John",
1597+
last_name: "Doe",
1598+
address_1: "789 Business Blvd",
1599+
city: "Chicago",
1600+
country_code: "us",
1601+
postal_code: "60601",
1602+
},
1603+
})
1604+
1605+
const shippingAddressId = cart.shipping_address?.id
1606+
const billingAddressId = cart.billing_address?.id
1607+
1608+
await updateCartWorkflow(appContainer).run({
1609+
input: {
1610+
id: cart.id,
1611+
shipping_address: {
1612+
id: shippingAddressId,
1613+
first_name: "Jane",
1614+
last_name: "Smith",
1615+
address_1: "456 Oak Ave",
1616+
city: "Los Angeles",
1617+
country_code: "us",
1618+
postal_code: "90001",
1619+
},
1620+
billing_address: {
1621+
id: billingAddressId,
1622+
first_name: "Jane",
1623+
last_name: "Smith",
1624+
address_1: "321 Corporate Dr",
1625+
city: "San Francisco",
1626+
country_code: "us",
1627+
postal_code: "94102",
1628+
},
1629+
},
1630+
})
1631+
1632+
cart = await cartModuleService.retrieveCart(cart.id, {
1633+
relations: ["shipping_address", "billing_address"],
1634+
})
1635+
1636+
expect(cart.shipping_address).toEqual(
1637+
expect.objectContaining({
1638+
id: shippingAddressId,
1639+
first_name: "Jane",
1640+
last_name: "Smith",
1641+
address_1: "456 Oak Ave",
1642+
city: "Los Angeles",
1643+
postal_code: "90001",
1644+
})
1645+
)
1646+
1647+
expect(cart.billing_address).toEqual(
1648+
expect.objectContaining({
1649+
id: billingAddressId,
1650+
first_name: "Jane",
1651+
last_name: "Smith",
1652+
address_1: "321 Corporate Dr",
1653+
city: "San Francisco",
1654+
postal_code: "94102",
1655+
})
1656+
)
1657+
})
1658+
1659+
it("should rollback address updates on workflow failure", async () => {
1660+
const salesChannel = await scModuleService.createSalesChannels({
1661+
name: "Webshop",
1662+
})
1663+
1664+
const regions = await regionModuleService.createRegions([
1665+
{
1666+
name: "US",
1667+
currency_code: "usd",
1668+
countries: ["us"],
1669+
},
1670+
])
1671+
1672+
let cart = await cartModuleService.createCarts({
1673+
currency_code: "usd",
1674+
sales_channel_id: salesChannel.id,
1675+
region_id: regions[0].id,
1676+
shipping_address: {
1677+
first_name: "John",
1678+
last_name: "Doe",
1679+
address_1: "123 Main St",
1680+
city: "New York",
1681+
country_code: "us",
1682+
postal_code: "10001",
1683+
},
1684+
})
1685+
1686+
const originalShippingAddress = { ...cart.shipping_address }
1687+
const shippingAddressId = cart.shipping_address?.id
1688+
1689+
const workflow = updateCartWorkflow(appContainer)
1690+
1691+
workflow.appendAction("throw", "update-carts", {
1692+
invoke: async function failStep() {
1693+
throw new Error("Simulated failure")
1694+
},
1695+
})
1696+
1697+
const { errors } = await workflow.run({
1698+
input: {
1699+
id: cart.id,
1700+
shipping_address: {
1701+
id: shippingAddressId,
1702+
first_name: "Jane",
1703+
last_name: "Smith",
1704+
address_1: "456 Oak Ave",
1705+
city: "Los Angeles",
1706+
country_code: "us",
1707+
postal_code: "90001",
1708+
},
1709+
},
1710+
throwOnError: false,
1711+
})
1712+
1713+
expect(errors).toBeDefined()
1714+
expect(errors?.length).toBeGreaterThan(0)
1715+
1716+
cart = await cartModuleService.retrieveCart(cart.id, {
1717+
relations: ["shipping_address"],
1718+
})
1719+
1720+
expect(cart.shipping_address).toEqual(
1721+
expect.objectContaining({
1722+
id: shippingAddressId,
1723+
first_name: originalShippingAddress.first_name,
1724+
last_name: originalShippingAddress.last_name,
1725+
address_1: originalShippingAddress.address_1,
1726+
city: originalShippingAddress.city,
1727+
postal_code: originalShippingAddress.postal_code,
1728+
})
1729+
)
1730+
})
14471731
})
14481732

14491733
describe("AddToCartWorkflow", () => {

packages/core/core-flows/src/cart/steps/update-carts.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
ICartModuleService,
3+
UpdateAddressDTO,
34
UpdateCartDTO,
45
UpdateCartWorkflowInputDTO,
56
} from "@medusajs/framework/types"
@@ -46,17 +47,53 @@ export const updateCartsStep = createStep(
4647
{ select: selects, relations }
4748
)
4849

50+
// Since service factory udpate method will correctly keep the reference to the addresses,
51+
// but won't update its fields, we do this separately
52+
const addressesInput = data
53+
.flatMap((cart) => [cart.shipping_address, cart.billing_address])
54+
.filter((address) => !!address)
55+
let addressesToUpdateIds: string[] = []
56+
const addressesToUpdate = addressesInput.filter(
57+
(address): address is UpdateAddressDTO => {
58+
if ("id" in address && !!address.id) {
59+
addressesToUpdateIds.push(address.id as string)
60+
return true
61+
}
62+
return false
63+
}
64+
)
65+
const addressesBeforeUpdate = await cartModule.listAddresses({
66+
id: addressesToUpdate.map((address) => address.id),
67+
})
68+
if (addressesToUpdate.length) {
69+
await cartModule.updateAddresses(addressesToUpdate)
70+
}
71+
4972
const updatedCart = await cartModule.updateCarts(data)
5073

51-
return new StepResponse(updatedCart, cartsBeforeUpdate)
74+
return new StepResponse(updatedCart, {
75+
cartsBeforeUpdate,
76+
addressesBeforeUpdate,
77+
})
5278
},
53-
async (cartsBeforeUpdate, { container }) => {
54-
if (!cartsBeforeUpdate) {
79+
async (dataToCompensate, { container }) => {
80+
if (!dataToCompensate) {
5581
return
5682
}
5783

84+
const { cartsBeforeUpdate, addressesBeforeUpdate } = dataToCompensate
85+
5886
const cartModule = container.resolve<ICartModuleService>(Modules.CART)
5987

88+
const addressesToUpdate: UpdateAddressDTO[] = []
89+
for (const address of addressesBeforeUpdate) {
90+
addressesToUpdate.push({
91+
...address,
92+
metadata: address.metadata ?? undefined
93+
})
94+
}
95+
await cartModule.updateAddresses(addressesToUpdate)
96+
6097
const dataToUpdate: UpdateCartDTO[] = []
6198

6299
for (const cart of cartsBeforeUpdate) {

packages/medusa/src/api/store/carts/validators.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,17 @@ export const StoreRemoveCartPromotions = z
4343
})
4444
.strict()
4545

46+
const StoreCartUpsertAddress = AddressPayload.merge(z.object({
47+
id: z.string().optional(),
48+
}))
49+
4650
export type StoreUpdateCartType = z.infer<typeof UpdateCart>
4751
export const UpdateCart = z
4852
.object({
4953
region_id: z.string().optional(),
5054
email: z.string().email().nullish(),
51-
billing_address: z.union([AddressPayload, z.string()]).optional(),
52-
shipping_address: z.union([AddressPayload, z.string()]).optional(),
55+
billing_address: z.union([StoreCartUpsertAddress, z.string()]).optional(),
56+
shipping_address: z.union([StoreCartUpsertAddress, z.string()]).optional(),
5357
sales_channel_id: z.string().nullish(),
5458
metadata: z.record(z.unknown()).nullish(),
5559
promo_codes: z.array(z.string()).optional(),

0 commit comments

Comments
 (0)