Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
6 changes: 6 additions & 0 deletions packages/api/src/__generated__/schema.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 17 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,36 @@ 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 orderFormPath = id ? `/${id}` : ''
// Always has at least `sc` (new cart) or `refreshOutdatedData` (existing).
const url = `${base}/api/checkout/pub/orderForm${orderFormPath}?${params}`

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

import { parse } from 'cookie'
import { channelWhenSessionDivergesFromOrderForm } from '../utils/cartSalesChannel'
import { mutateChannelContext, mutateLocaleContext } from '../utils/contex'
import { md5 } from '../utils/md5'
import {
Expand Down Expand Up @@ -175,7 +176,8 @@
const orderFormToCart = async (
form: OrderForm,
skuLoader: GraphqlContext['loaders']['skuLoader'],
shouldSplitItem?: boolean | null
shouldSplitItem?: boolean | null,
adoptedSalesChannel?: string | null
) => {
return {
order: {
Expand All @@ -185,6 +187,7 @@
product: await skuLoader.load(`${item.id}-invisibleItems`),
})),
shouldSplitItem,
...(adoptedSalesChannel ? { salesChannel: adoptedSalesChannel } : {}),
},
messages: form.messages.map(({ text, status }) => ({
text,
Expand Down Expand Up @@ -336,6 +339,24 @@
return cookieValue ? cookieValue.split('=')[1] : ''
}

/** Keep Checkout on the orderForm SC when the browser session lags behind it. */
const adoptOrderFormSalesChannelWhenSessionDiverges = (
ctx: GraphqlContext,
orderForm: OrderForm
): string | null => {
const adoptedChannel = channelWhenSessionDivergesFromOrderForm(
ctx.storage.channel,
orderForm.salesChannel
)

if (adoptedChannel) {
mutateChannelContext(ctx, adoptedChannel)
return orderForm.salesChannel ? String(orderForm.salesChannel) : null
}

return null
}

/**
* 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 All @@ -353,7 +374,7 @@
_: unknown,
{ cart: { order }, session }: MutationValidateCartArgs,
ctx: GraphqlContext
) => {

Check failure on line 377 in packages/api/src/platforms/vtex/resolvers/validateCart.ts

View check run for this annotation

Sonar - Workflows / SonarQube Code Analysis

packages/api/src/platforms/vtex/resolvers/validateCart.ts#L377

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.
const orderFormIdFromCookie = getCookieCheckoutOrderNumber(
ctx.headers.cookie,
'checkout.vtex.com'
Expand All @@ -374,10 +395,17 @@
mutateLocaleContext(ctx, locale)
}

// Step1: Get OrderForm from VTEX Commerce
// 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
const orderForm = await commerce.checkout.orderForm({
id: orderFormIdFromCookie || undefined,
id: orderFormId,
channel: ctx.storage.channel,
preserveSalesChannel: Boolean(orderFormId),
})
const orderNumber = orderForm.orderFormId

Expand All @@ -398,16 +426,36 @@
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.
const adoptedSalesChannel = adoptOrderFormSalesChannelWhenSessionDiverges(
ctx,
orderForm
)

const newOrderForm = await setOrderFormEtag(
orderForm,
commerce,
sessionJwt
).then(joinItems)
if (orderNumber) {
return orderFormToCart(newOrderForm, skuLoader, shouldSplitItem)
return orderFormToCart(
newOrderForm,
skuLoader,
shouldSplitItem,
adoptedSalesChannel
)
}
}

// Keep Checkout on the orderForm trade policy when the browser session still
// has a stale SC (Quick Order). Refetching with `sc=session` would wipe
// items that exist only on the orderForm's sales channel.
const adoptedSalesChannel = adoptOrderFormSalesChannelWhenSessionDiverges(
ctx,
orderForm
)

// 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 Expand Up @@ -470,8 +518,18 @@
? shouldUpdateShippingData(orderForm, session)
: { updateShipping: false }

// If there are no item changes and no shipping data updates needed, return null
// If there are no item/shipping changes: still return the cart when we
// adopted the orderForm SC so the client can align `fs::session`.
if (changes.length === 0 && !updateShipping) {
if (adoptedSalesChannel) {
return orderFormToCart(
orderForm,
skuLoader,
shouldSplitItem,
adoptedSalesChannel
)
}

return null
}

Expand Down Expand Up @@ -541,9 +599,23 @@

// Step5: If no changes detected before/after updating orderForm, the order is validated
if (equals(order, updatedOrderForm) && equalMessages) {
if (adoptedSalesChannel) {
return orderFormToCart(
updatedOrderForm,
skuLoader,
shouldSplitItem,
adoptedSalesChannel
)
}

return null
}

// Step6: There were changes, convert orderForm to StoreCart
return orderFormToCart(updatedOrderForm, skuLoader, shouldSplitItem)
return orderFormToCart(
updatedOrderForm,
skuLoader,
shouldSplitItem,
adoptedSalesChannel
)
}
13 changes: 11 additions & 2 deletions packages/api/src/platforms/vtex/resolvers/validateSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
_: any,
{ session: oldSession, search }: MutationValidateSessionArgs,
{ clients, headers, account }: GraphqlContext
): Promise<StoreSession | null> => {

Check failure on line 45 in packages/api/src/platforms/vtex/resolvers/validateSession.ts

View check run for this annotation

Sonar - Workflows / SonarQube Code Analysis

packages/api/src/platforms/vtex/resolvers/validateSession.ts#L45

Refactor this function to reduce its Cognitive Complexity from 32 to the 15 allowed.
const channel = ChannelMarshal.parse(oldSession.channel ?? '')
const postalCode = String(oldSession.postalCode ?? '')
const country = oldSession.country ?? ''
Expand Down Expand Up @@ -170,10 +170,19 @@
},
country: store?.countryCode?.value ?? country,
channel: ChannelMarshal.stringify({
salesChannel: store?.channel?.value ?? channel.salesChannel,
// When the client already pinned an explicit SC (e.g. adopted from the
// orderForm after Quick Order), keep it. Session Manager often still
// reports the default SC and would otherwise overwrite the adoption.
salesChannel:
channel.hasOnlyDefaultSalesChannel === false
? channel.salesChannel || store?.channel?.value
: (store?.channel?.value ?? channel.salesChannel),
regionId: checkout?.regionId?.value ?? channel.regionId,
seller: seller?.id,
hasOnlyDefaultSalesChannel: !store?.channel?.value,
hasOnlyDefaultSalesChannel:
channel.hasOnlyDefaultSalesChannel === false
? false
: !store?.channel?.value,
}),
/**
* B2B data structure in Session:
Expand Down
6 changes: 6 additions & 0 deletions packages/api/src/platforms/vtex/typeDefs/order.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ type StoreOrder {
Indicates whether or not items with attachments should be split.
"""
shouldSplitItem: Boolean
"""
Sales channel of the underlying orderForm when FastStore adopted it because
the browser session was stale (e.g. after Quick Order). Clients should align
`session.channel` to this value. Null when no SC adoption happened.
"""
salesChannel: String
}

"""
Expand Down
31 changes: 31 additions & 0 deletions packages/api/src/platforms/vtex/utils/cartSalesChannel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Channel } from './channel'
import ChannelMarshal from './channel'

/**
* When session SC and orderForm SC diverge, keep Checkout on the orderForm
* trade policy. Used after external cart changes (stale etag / Quick Order) and
* on later validations while the browser session still lags.
*
* Forcing the session SC (refetch / item updates with `sc=session`) would drop
* items that exist only on the orderForm's sales channel.
*/
export function channelWhenSessionDivergesFromOrderForm(
currentChannel: Required<Channel>,
orderFormSalesChannel: string | null | undefined
): string | null {
if (orderFormSalesChannel == null || orderFormSalesChannel === '') {
return null
}

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

return ChannelMarshal.stringify({
...currentChannel,
salesChannel: String(orderFormSalesChannel),
hasOnlyDefaultSalesChannel: false,
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ exports[`\`validateCart\` mutation should return new cart when etag is stale 1`]
},
],
"orderNumber": "edbe3b03c8c94827a37ec5a6a4648fd2",
"salesChannel": null,
},
},
},
Expand Down Expand Up @@ -54,6 +55,7 @@ exports[`\`validateCart\` mutation should return the full order when an invalid
},
],
"orderNumber": "edbe3b03c8c94827a37ec5a6a4648fd2",
"salesChannel": null,
},
},
},
Expand Down
Loading
Loading