Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
20 changes: 18 additions & 2 deletions packages/api/src/platforms/vtex/clients/commerce/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,21 +435,37 @@ export const VtexCommerce = (
id,
refreshOutdatedData = true,
channel = ctx.storage.channel,
preserveSalesChannel = false,
}: {
id?: string
refreshOutdatedData?: boolean
channel?: Required<Channel>
/**
* When true, omit the `sc` query param so Checkout keeps the cart's
* current sales channel. Use this when the session SC may be stale
* relative to an external flow (e.g. Quick Order) that already
* advanced the orderForm.
*/
preserveSalesChannel?: boolean
}): Promise<OrderForm> => {
const { salesChannel } = channel
const headers: HeadersInit = withCookie({
'content-type': 'application/json',
'X-FORWARDED-HOST': forwardedHost,
})
const params = new URLSearchParams({ sc: salesChannel })
const params = new URLSearchParams()
// New carts (no id) always need an explicit SC. Existing carts may
// omit it so Checkout does not recalculate under a stale session SC.
if (!preserveSalesChannel || !id) {
params.set('sc', salesChannel)
}
if (id) {
params.set('refreshOutdatedData', refreshOutdatedData.toString())
}
const url = `${base}/api/checkout/pub/orderForm${id ? `/${id}` : ''}?${params.toString()}`
const qs = params.toString()
const orderFormPath = id ? `/${id}` : ''
const queryString = qs ? `?${qs}` : ''
const url = `${base}/api/checkout/pub/orderForm${orderFormPath}${queryString}`

return fetchAPI(
url,
Expand Down
55 changes: 52 additions & 3 deletions packages/api/src/platforms/vtex/resolvers/validateCart.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import deepEquals from 'fast-deep-equal'

import { parse } from 'cookie'
import {
channelAfterExternalOrderFormSync,
shouldRefetchOrderFormWithSessionSalesChannel,
} from '../utils/cartSalesChannel'
import { mutateChannelContext, mutateLocaleContext } from '../utils/contex'
import { md5 } from '../utils/md5'
import {
Expand Down Expand Up @@ -336,6 +340,23 @@ const getCookieCheckoutOrderNumber = (ctx: string, nameCookie: string) => {
return cookieValue ? cookieValue.split('=')[1] : ''
}

/** Adopt the orderForm SC when another system changed the cart (stale etag). */
const adoptOrderFormSalesChannelIfNeeded = (
ctx: GraphqlContext,
orderForm: OrderForm,
isOrderFormStaleFlag: boolean
) => {
const adoptedChannel = channelAfterExternalOrderFormSync(
ctx.storage.channel,
orderForm.salesChannel,
isOrderFormStaleFlag
)

if (adoptedChannel) {
mutateChannelContext(ctx, adoptedChannel)
}
}

/**
* This resolver implements the optimistic cart behavior. The main idea in here
* is that we receive a cart from the UI (as query params) and we validate it with
Expand Down Expand Up @@ -374,10 +395,17 @@ export const validateCart = async (
mutateLocaleContext(ctx, locale)
}

// Step1: Get OrderForm from VTEX Commerce
const orderForm = await commerce.checkout.orderForm({
id: orderFormIdFromCookie || undefined,
// Step1: Get OrderForm from VTEX Commerce.
// For existing carts (`orderFormId` present), omit `sc` on the first GET so
// Checkout keeps the orderForm's current sales channel. Passing a stale
// session SC (e.g. after Quick Order) would recalculate the cart and drop
// items only available in the orderForm's trade policy. New carts still
// send `sc` from the session (see commerce.checkout.orderForm).
const orderFormId = orderFormIdFromCookie || undefined
let orderForm = await commerce.checkout.orderForm({
id: orderFormId,
channel: ctx.storage.channel,
preserveSalesChannel: Boolean(orderFormId),
})
const orderNumber = orderForm.orderFormId

Expand All @@ -398,6 +426,10 @@ export const validateCart = async (
const isStale = isOrderFormStale(orderForm, sessionJwt)

if (isStale) {
// Adopt the orderForm SC so subsequent checkout calls (etag, etc.) stay
// on the trade policy that actually owns the items.
adoptOrderFormSalesChannelIfNeeded(ctx, orderForm, isStale)

const newOrderForm = await setOrderFormEtag(
orderForm,
commerce,
Expand All @@ -408,6 +440,23 @@ export const validateCart = async (
}
}

// Session owns the channel when the cart is not externally stale. If the
// user switched sales channel in-session (e.g. locale/binding), re-fetch
// with the session SC so Checkout recalculates under the new trade policy.
if (
shouldRefetchOrderFormWithSessionSalesChannel(
ctx.storage.channel.salesChannel,
orderForm.salesChannel,
isStale
)
) {
orderForm = await commerce.checkout.orderForm({
id: orderForm.orderFormId,
channel: ctx.storage.channel,
preserveSalesChannel: false,
})
}

// Step2: Process items from both browser and checkout so they have the same shape
const browserItemsById = groupById(acceptedOffer)
const originItemsById = groupById(orderForm.items.map(orderFormItemToOffer))
Expand Down
55 changes: 55 additions & 0 deletions packages/api/src/platforms/vtex/utils/cartSalesChannel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Channel } from './channel'
import ChannelMarshal from './channel'

/**
* When another system changed the orderForm (stale cartEtag), the orderForm's
* sales channel is authoritative. Returns a channel string to apply via
* `mutateChannelContext`, or `null` when no adoption is needed.
*/
export function channelAfterExternalOrderFormSync(
currentChannel: Required<Channel>,
orderFormSalesChannel: string | null | undefined,
isOrderFormStale: boolean
): string | null {
if (!isOrderFormStale) {
return null
}

if (orderFormSalesChannel == null || orderFormSalesChannel === '') {
return null
}

if (
String(orderFormSalesChannel) === String(currentChannel.salesChannel ?? '')
) {
return null
}

return ChannelMarshal.stringify({
...currentChannel,
salesChannel: String(orderFormSalesChannel),
hasOnlyDefaultSalesChannel: false,
})
}

/**
* After a non-stale validation, the session owns the channel. If session SC
* differs from the orderForm's SC (e.g. locale/binding switch), the cart must
* be re-fetched with the session SC so Checkout recalculates under the new
* trade policy.
*/
export function shouldRefetchOrderFormWithSessionSalesChannel(
sessionSalesChannel: string | undefined,
orderFormSalesChannel: string | null | undefined,
isOrderFormStale: boolean
): boolean {
if (isOrderFormStale) {
return false
}

if (!sessionSalesChannel || !orderFormSalesChannel) {
return false
}

return String(sessionSalesChannel) !== String(orderFormSalesChannel)
}
6 changes: 3 additions & 3 deletions packages/api/test/mocks/ValidateCartMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ export const InvalidCart = {
// Valid Cart

export const checkoutOrderFormValidFetch = {
info: 'https://storeframework.vtexcommercestable.com.br/api/checkout/pub/orderForm/edbe3b03c8c94827a37ec5a6a4648fd2?sc=1&refreshOutdatedData=true',
info: 'https://storeframework.vtexcommercestable.com.br/api/checkout/pub/orderForm/edbe3b03c8c94827a37ec5a6a4648fd2?refreshOutdatedData=true',
init: {
method: 'POST',
headers: { 'content-type': 'application/json', 'X-FORWARDED-HOST': '' },
Expand All @@ -289,7 +289,7 @@ export const checkoutOrderFormCustomDataValidFetch = {
// "Invalid" Cart

export const checkoutOrderFormInvalidFetch = {
info: 'https://storeframework.vtexcommercestable.com.br/api/checkout/pub/orderForm/edbe3b03c8c94827a37ec5a6a4648fd2?sc=1&refreshOutdatedData=true',
info: 'https://storeframework.vtexcommercestable.com.br/api/checkout/pub/orderForm/edbe3b03c8c94827a37ec5a6a4648fd2?refreshOutdatedData=true',
init: {
method: 'POST',
headers: { 'content-type': 'application/json', 'X-FORWARDED-HOST': '' },
Expand Down Expand Up @@ -515,7 +515,7 @@ export const createProductFetchResultForSku = (

// Stale Cart
export const checkoutOrderFormStaleFetch = {
info: 'https://storeframework.vtexcommercestable.com.br/api/checkout/pub/orderForm/edbe3b03c8c94827a37ec5a6a4648fd2?sc=1&refreshOutdatedData=true',
info: 'https://storeframework.vtexcommercestable.com.br/api/checkout/pub/orderForm/edbe3b03c8c94827a37ec5a6a4648fd2?refreshOutdatedData=true',
init: {
method: 'POST',
headers: { 'content-type': 'application/json', 'X-FORWARDED-HOST': '' },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as clients from '../../../../../src/platforms/vtex/clients'
// This should be imported AFTER the '../../../../../src/platforms/vtex/clients'
import { NotFoundError } from '../../../../../src/platforms/errors'
import { GraphqlVtexContextFactory } from '../../../../../src/platforms/vtex'
import * as clients from '../../../../../../src/platforms/vtex/clients'
// This should be imported AFTER the '../../../../../../src/platforms/vtex/clients'
import { NotFoundError } from '../../../../../../src/platforms/errors'
import { GraphqlVtexContextFactory } from '../../../../../../src/platforms/vtex'

const apiOptions = {
platform: 'vtex',
Expand All @@ -27,7 +27,7 @@ beforeEach(() => {
fetchAPIMocked.mockClear()
})

vi.mock('../../../../../src/platforms/vtex/clients/fetch.ts', () => ({
vi.mock('../../../../../../src/platforms/vtex/clients/fetch.ts', () => ({
fetchAPI: async (
info: RequestInfo,
init?: RequestInit,
Expand Down Expand Up @@ -71,6 +71,61 @@ describe('VTEX Commerce', () => {
expect(fetchAPIMocked).not.toHaveBeenCalled()
})
})

describe('orderForm', () => {
it('includes sc query param by default', async () => {
fetchAPIMocked.mockResolvedValueOnce({
orderFormId: 'of-1',
salesChannel: '1',
items: [],
})

const { commerce } = clients.getClients(apiOptions, context)
await commerce.checkout.orderForm({ id: 'of-1' })

const [url] = fetchAPIMocked.mock.calls[0]
expect(url).toContain('/api/checkout/pub/orderForm/of-1?')
expect(url).toContain('sc=1')
expect(url).toContain('refreshOutdatedData=true')
})

it('omits sc when preserveSalesChannel is true for an existing cart', async () => {
fetchAPIMocked.mockResolvedValueOnce({
orderFormId: 'of-1',
salesChannel: '4',
items: [{ id: 'sku-1' }],
})

const { commerce } = clients.getClients(apiOptions, context)
await commerce.checkout.orderForm({
id: 'of-1',
preserveSalesChannel: true,
})

const [url] = fetchAPIMocked.mock.calls[0]
expect(url).toContain('/api/checkout/pub/orderForm/of-1?')
expect(url).not.toContain('sc=')
expect(url).toContain('refreshOutdatedData=true')
})

it('still sends sc when preserveSalesChannel is true but creating a new cart', async () => {
fetchAPIMocked.mockResolvedValueOnce({
orderFormId: 'of-new',
salesChannel: '1',
items: [],
})

const { commerce } = clients.getClients(apiOptions, context)
await commerce.checkout.orderForm({
preserveSalesChannel: true,
})

const [url] = fetchAPIMocked.mock.calls[0]
expect(url).toContain('/api/checkout/pub/orderForm?')
expect(url).toContain('sc=1')
expect(url).not.toContain('refreshOutdatedData')
})
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

describe('Order Entry', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import {
channelAfterExternalOrderFormSync,
shouldRefetchOrderFormWithSessionSalesChannel,
} from '../../../../../src/platforms/vtex/utils/cartSalesChannel'
import type { Channel } from '../../../../../src/platforms/vtex/utils/channel'

const baseChannel = (salesChannel: string): Required<Channel> => ({
salesChannel,
regionId: '',
seller: '',
hasOnlyDefaultSalesChannel: true,
})

describe('channelAfterExternalOrderFormSync', () => {
it('returns null when the orderForm is not stale', () => {
expect(
channelAfterExternalOrderFormSync(baseChannel('1'), '4', false)
).toBeNull()
})

it('returns null when orderForm SC matches session SC', () => {
expect(
channelAfterExternalOrderFormSync(baseChannel('4'), '4', true)
).toBeNull()
})

it('returns null when orderForm SC is missing', () => {
expect(
channelAfterExternalOrderFormSync(baseChannel('1'), null, true)
).toBeNull()
expect(
channelAfterExternalOrderFormSync(baseChannel('1'), '', true)
).toBeNull()
})

it('adopts orderForm SC when stale and divergent (Quick Order case)', () => {
const result = channelAfterExternalOrderFormSync(
baseChannel('1'),
'4',
true
)

if (result === null) {
throw new Error('expected channel string after adopting orderForm SC')
}

expect(JSON.parse(result)).toMatchObject({
salesChannel: '4',
hasOnlyDefaultSalesChannel: false,
})
})
})

describe('shouldRefetchOrderFormWithSessionSalesChannel', () => {
it('does not refetch when the orderForm is externally stale', () => {
expect(shouldRefetchOrderFormWithSessionSalesChannel('1', '4', true)).toBe(
false
)
})

it('does not refetch when SCs already match', () => {
expect(shouldRefetchOrderFormWithSessionSalesChannel('1', '1', false)).toBe(
false
)
})

it('refetches when session SC diverges and cart is not stale (locale switch)', () => {
expect(shouldRefetchOrderFormWithSessionSalesChannel('2', '1', false)).toBe(
true
)
})

it('does not refetch when either SC is missing', () => {
expect(
shouldRefetchOrderFormWithSessionSalesChannel(undefined, '1', false)
).toBe(false)
expect(
shouldRefetchOrderFormWithSessionSalesChannel('1', null, false)
).toBe(false)
})
})
Loading