Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- `addToCart` no longer collapses an add into the quantity of an existing
attachment-less line for the same SKU when the new item carries assembly
options (e.g. B2B `quoteData`). The resolver now sets the per-item
`forceNewEntry` flag (introduced by CHK-5575 in the checkout REST engine)
on items that carry `options`, instructing the engine to bypass both its
`AddItemsAsync` merge lookup and the pipeline `MergeItems` step so that
the clean addItem produces a distinct line and the follow-up
`addAssemblyOptions` attaches to it. Plain adds (no `options`) are
unchanged.

## [0.67.2] - 2026-05-04

### Changed
Expand Down
140 changes: 140 additions & 0 deletions node/__tests__/items-mutations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,146 @@ describe('mutations.addToCart — items with options', () => {
})
})

/**
* `forceNewEntry` (per-item flag on the addItem REST payload — CHK-5575) is
* how the resolver instructs the checkout engine to bypass its built-in cart
* merge logic (both `AddItemsAsync` and the pipeline `MergeItems` step) for a
* specific item. The resolver MUST set it for items that carry `options`
* (assembly options / attachments such as B2B `quoteData`), because those are
* added in two steps (clean addItem + addAssemblyOptions). Without the flag
* the clean addItem can merge into a pre-existing line with the same
* SKU + seller + no attachments, leaving phase 2 with no new line to attach
* the option to — the user-visible bug being a silent quantity bump instead
* of a new distinct line.
*
* The resolver MUST NOT set the flag for plain items (no options), because
* plain adds must preserve today's behavior of merging same-SKU lines.
*/
describe('mutations.addToCart — forceNewEntry on items with options', () => {
it('sends forceNewEntry: true on the cleanItems entry for an item that carries options', async () => {
const ctx = setupCtx()

ctx.clients.checkout.orderForm
.mockResolvedValueOnce(orderFormWith({ items: [] }))
.mockResolvedValueOnce(orderFormWith({ orderFormId: 'fresh' }))
ctx.clients.checkout.addItem.mockResolvedValue(orderFormWith())

const items = [
{
id: 'sku-with-options',
quantity: 1,
seller: '1',
options: [
{
assemblyId: 'quoteData',
id: 'q-1',
quantity: 1,
seller: '1',
inputValues: { quoteId: 'A' },
},
],
},
] as any

await mutations.addToCart(
null,
{ orderFormId: 'of-1', items },
toContext(ctx)
)

const [, cleanItems] = (ctx.clients.checkout.addItem as jest.Mock).mock
.calls[0]
expect(cleanItems).toEqual([
{ id: 'sku-with-options', quantity: 1, seller: '1', forceNewEntry: true },
])
})

it('does not send forceNewEntry on the cleanItems entry for an item without options', async () => {
const ctx = setupCtx()

ctx.clients.checkout.orderForm.mockResolvedValue(orderFormWith({ items: [] }))
ctx.clients.checkout.addItem.mockResolvedValue(orderFormWith())

const items = [{ id: 'plain', quantity: 1, seller: '1' }] as any

await mutations.addToCart(
null,
{ orderFormId: 'of-1', items },
toContext(ctx)
)

const [, cleanItems] = (ctx.clients.checkout.addItem as jest.Mock).mock
.calls[0]
expect(cleanItems).toEqual([{ id: 'plain', quantity: 1, seller: '1' }])
expect(cleanItems[0]).not.toHaveProperty('forceNewEntry')
})

it('sends forceNewEntry only on items that carry options when the batch is mixed', async () => {
const ctx = setupCtx()

ctx.clients.checkout.orderForm
.mockResolvedValueOnce(orderFormWith({ items: [] }))
.mockResolvedValueOnce(orderFormWith({ orderFormId: 'fresh' }))
ctx.clients.checkout.addItem.mockResolvedValue(orderFormWith())

const items = [
{
id: 'with-opts',
quantity: 1,
seller: '1',
options: [
{
assemblyId: 'addon',
id: 'a-1',
quantity: 1,
seller: '1',
inputValues: {},
},
],
},
{ id: 'plain', quantity: 3, seller: '1' },
] as any

await mutations.addToCart(
null,
{ orderFormId: 'of-1', items },
toContext(ctx)
)

const [, cleanItems] = (ctx.clients.checkout.addItem as jest.Mock).mock
.calls[0]
expect(cleanItems).toEqual([
{ id: 'with-opts', quantity: 1, seller: '1', forceNewEntry: true },
{ id: 'plain', quantity: 3, seller: '1' },
])
expect(cleanItems[1]).not.toHaveProperty('forceNewEntry')
})

it('does not send forceNewEntry when the options array is present but empty', async () => {
const ctx = setupCtx()

ctx.clients.checkout.orderForm.mockResolvedValue(orderFormWith({ items: [] }))
ctx.clients.checkout.addItem.mockResolvedValue(orderFormWith())

const items = [
{ id: 'no-real-opts', quantity: 1, seller: '1', options: [] },
] as any

await mutations.addToCart(
null,
{ orderFormId: 'of-1', items },
toContext(ctx)
)

const [, cleanItems] = (ctx.clients.checkout.addItem as jest.Mock).mock
.calls[0]
expect(cleanItems).toEqual([
{ id: 'no-real-opts', quantity: 1, seller: '1' },
])
expect(cleanItems[0]).not.toHaveProperty('forceNewEntry')
})
})

describe('mutations.updateItems', () => {
// The resolver fetches the current orderForm whenever:
// (a) it inspects a single targeted item for subscription attachments, or
Expand Down
6 changes: 5 additions & 1 deletion node/clients/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ export class Checkout extends JanusClient {

public addItem = (
orderFormId: string,
items: Array<Omit<OrderFormItemInput, 'uniqueId' | 'index' | 'options'>>,
items: Array<
Omit<OrderFormItemInput, 'uniqueId' | 'index' | 'options'> & {
forceNewEntry?: boolean
}
>,
salesChannel?: string,
allowedOutdatedData?: string[]
) =>
Expand Down
5 changes: 4 additions & 1 deletion node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,16 @@
"@types/node": "^12.0.0",
"@types/ramda": "types/npm-ramda#dist",
"@types/set-cookie-parser": "^2.4.2",
"@vtex/api": "6.46.1",
"@vtex/api": "6.50.1",
"@vtex/test-tools": "^3.1.0",
"@vtex/tsconfig": "^0.2.0",
"typescript": "3.9.7",
"vtex.checkout-graphql": "http://vtex.vtexassets.com/_v/public/typings/v1/vtex.checkout-graphql@0.60.0/public/@types/vtex.checkout-graphql",
"vtex.country-data-settings": "http://vtex.vtexassets.com/_v/public/typings/v1/vtex.country-data-settings@0.4.0/public/@types/vtex.country-data-settings",
"vtex.graphql-server": "http://vtex.vtexassets.com/_v/public/typings/v1/vtex.graphql-server@1.66.4/public/_types/react",
"vtex.messages": "http://vtex.vtexassets.com/_v/public/typings/v1/vtex.messages@1.64.0/public/@types/vtex.messages"
},
"resolutions": {
"@opentelemetry/api": "1.8.0"
}
}
18 changes: 15 additions & 3 deletions node/resolvers/items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,21 @@ export const mutations = {
Object.keys(marketingData ?? {}).length > 0

const { items: previousItems } = await checkout.orderForm(orderFormId!)
const cleanItems = items.map(
({ options, index, uniqueId, ...rest }) => rest
)
/**
* Items that carry `options` (assembly options / attachments such as B2B
* `quoteData`) are added in two REST calls: a "clean" addItem here, then a
* follow-up addAssemblyOptions in `addOptionsForItems`. Without
* `forceNewEntry`, the checkout engine would merge the clean addItem into
* any pre-existing line with the same SKU + seller + no attachments,
* leaving phase 2 with no new line to attach the options to. The flag
* tells the engine to bypass both the AddItemsAsync merge lookup and the
* pipeline-level MergeItems step, guaranteeing a new line is created and
* the subsequent option attach lands on it. See CHK-5575.
*/
const cleanItems = items.map(({ options, index, uniqueId, ...rest }) => {
const hasOptions = !!options && options.length > 0
return hasOptions ? { ...rest, forceNewEntry: true } : rest
})

const withOptions = items
.map((item, currentIndex) => ({
Expand Down
Loading
Loading