Skip to content
Open
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,6 @@ node/node_modules
react/package-lock.json
node/package-lock.json
node/node_modules
.vscode/
.vscode/
.claude
.cursor
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- [B2BTEAM-3733] Send `priceToken` (signed price) when adding the quote items to the cart in `useQuote`, so Checkout can still build the cart while Pricing is unavailable. The token is fetched from the catalog search at the moment the quote is applied and is optional: if it is not available, items are added exactly as before.

### Changed

- [B2BTEAM-3733] `useQuote` now uses `PATCH /orderForm/{id}/items` for both cart operations: adding the quote items (no `index` sent) and overwriting their price with the negotiated one (`index` sent, replacing `POST /orderForm/{id}/items/update`). `PATCH` is the only route that honors `priceToken`, and the previous routes are no longer meant to be used. The price payload now also carries `id` and `seller`, which `PATCH` requires.

## [4.0.6] - 2026-03-10

### Fixed
Expand Down
7 changes: 7 additions & 0 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@
"path": "/api/checkout/pub/*"
}
},
{
"name": "outbound-access",
"attrs": {
"host": "{{account}}.vtexcommercestable.com.br",
"path": "/api/catalog_system/pub/*"
}
},
{
"name": "outbound-access",
"attrs": {
Expand Down
55 changes: 55 additions & 0 deletions node/clients/catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type {
InstanceOptions,
IOContext,
RequestTracingConfig,
} from '@vtex/api'
import { JanusClient } from '@vtex/api'

import { APP_NAME } from '../constants'
import { createTracing } from '../utils/index'

const SEARCH_ENDPOINT = '/api/catalog_system/pub/products/search'

// The catalog search is only used to enrich the cart with price tokens, so it
// must never slow down or break the flow that depends on it.
const CATALOG_CLIENT_OPTIONS: InstanceOptions = {
retries: 1,
timeout: 3000,
}

export default class Catalog extends JanusClient {
constructor(ctx: IOContext, options?: InstanceOptions) {
super(ctx, {
...options,
...CATALOG_CLIENT_OPTIONS,
headers: {
...options?.headers,
Accept: 'application/json',
},
})
}

/**
* Searches products by SKU id. The response carries the signed price
* (`sellers[].commertialOffer.PriceToken`) generated for this request.
*/
public searchBySkuIds(
skuIds: string[],
salesChannel?: string | null,
tracingConfig?: RequestTracingConfig
) {
const metric = `${APP_NAME}-catalogSearchBySkuIds`

const filters = skuIds.map((skuId) => `fq=skuId:${skuId}`).join('&')
const salesChannelQueryString = salesChannel ? `&sc=${salesChannel}` : ''
const pagination = `&_from=0&_to=${skuIds.length - 1}`

return this.http.get<CatalogProduct[]>(
`${SEARCH_ENDPOINT}?${filters}${salesChannelQueryString}${pagination}`,
{
metric,
tracing: createTracing(metric, tracingConfig),
}
)
}
}
5 changes: 5 additions & 0 deletions node/clients/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import AnalyticsClient from './analytics'
import RequestHub from '../utils/Hub'
import Identity from '../utils/Identity'
import { Scheduler } from '../utils/Scheduler'
import Catalog from './catalog'
import Checkout from './checkout'
import LMClient from './LMClient'
import MailClient from './email'
Expand Down Expand Up @@ -53,6 +54,10 @@ export class Clients extends IOClients {
return this.getOrSet('vtexId', VtexId)
}

public get catalog() {
return this.getOrSet('catalog', Catalog)
}

public get checkout() {
return this.getOrSet('checkout', Checkout)
}
Expand Down
13 changes: 9 additions & 4 deletions node/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ export const B2B_USER_SCHEMA_VERSION = 'v0.1.2'
export const B2B_USER_DATA_ENTITY = 'b2b_users'
export const CRON_EXPRESSION = '0 */12 * * *'

// The catalog search API returns at most 50 products per request.
export const CATALOG_SEARCH_PAGE_SIZE = 50

export const QUOTE_FIELDS = [
'id',
'referenceName',
Expand Down Expand Up @@ -45,10 +48,12 @@ export const routes = {
`${routes.orderForm(account)}/${orderFormId}/customData/${appId}/${
property ?? ''
}`,
addPriceToItems: (account: string, orderFormId: string) =>
`${routes.orderForm(account)}/${orderFormId}/items/update`,
addToCart: (account: string, orderFormId: string) =>
`${routes.orderForm(account)}/${orderFormId}/items/`,
// Always call this with PATCH. It both adds items (no `index` sent) and
// changes their price (`index` sent), and it is the only route that honors
// `priceToken` - neither `POST /items` nor `POST /items/update` does, and
// they are no longer meant to be used.
cartItems: (account: string, orderFormId: string) =>
`${routes.orderForm(account)}/${orderFormId}/items`,
baseUrl: (account: string) =>
`http://${account}.vtexcommercestable.com.br/api`,
checkoutConfig: (account: string) =>
Expand Down
2 changes: 1 addition & 1 deletion node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"ramda": "^0.25.0",
"atob": "^2.1.2",
"axios": "0.27.2",
"@vtex/api": "6.50.1"
"@vtex/api": "6.51.0"
},
"devDependencies": {
"@types/atob": "^2.1.2",
Expand Down
63 changes: 50 additions & 13 deletions node/resolvers/mutations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
checkQuoteStatus,
checkSession,
} from '../utils/checkPermissions'
import { getPriceTokens, priceTokenKey } from '../utils/priceTokens'
import {
createItemComparator,
createQuoteObject,
Expand Down Expand Up @@ -563,18 +564,43 @@ export const Mutation = {
)
)

// GET SIGNED PRICES SO CHECKOUT CAN ADD THE ITEMS EVEN IF PRICING IS DOWN
const priceTokens = await getPriceTokens(ctx, {
skuIds: mergedItems.map((item) => item.id),
salesChannel,
})

const orderItemsToAdd = mergedItems.map((item) => {
const priceToken = priceTokens[priceTokenKey(item.id, item.seller)]

return priceToken ? { ...item, priceToken } : item
})

// Neither the addToCart response nor the orderForm reports whether the
// token was received or used, and the fallback only kicks in during a
// Pricing outage - so this is the only practical evidence that the tokens
// are getting through, and how the feature flag rollout is followed here.
logger.info({
message: 'useQuote-priceTokenCoverage',
itemsWithPriceToken: orderItemsToAdd.filter(
(item) => 'priceToken' in item
).length,
totalItems: orderItemsToAdd.length,
salesChannel,
})

// ADD ITEMS TO CART
const data = await hub
.post(
`${routes.addToCart(account, orderFormId)}${salesChannelQueryString}`,
{
expectedOrderFormSections: ['items'],
orderItems: mergedItems,
}
)
.then((res: any) => {
return res.data
})
// PATCH, not POST: only PATCH honors `priceToken`, and POST is no longer
// meant to be used. Omitting `index` on the items is what makes PATCH add
// them as new items instead of updating existing ones - which is the
// intent here, since the cart was cleared above.
const data = await hub.patch(
`${routes.cartItems(account, orderFormId)}${salesChannelQueryString}`,
{
expectedOrderFormSections: ['items'],
orderItems: orderItemsToAdd,
}
)

const { items: itemsAdded } = data

Expand All @@ -599,16 +625,27 @@ export const Mutation = {

quoteItemIndex++
const sellingData = sellingPriceMap[String(quoteItemIndex)]
const priceToken = priceTokens[priceTokenKey(item.id, item.seller)]

orderItems.push({
// `id` and `seller` are required by PATCH /items, unlike the
// POST /items/update this call used to make.
id: item.id,
seller: item.seller,
index: realIndex,
price: sellingData?.price,
quantity: sellingData?.quantity,
...(priceToken ? { priceToken } : {}),
})
})

await hub.post(
routes.addPriceToItems(account, orderFormId),
// APPLY THE NEGOTIATED PRICE
// Sending `index` is what makes PATCH change the existing items instead
// of adding new ones. This replaced POST /items/update, which does not
// honor `priceToken` either - so during a Pricing outage the price
// override would have failed even with the items already in the cart.
await hub.patch(
routes.cartItems(account, orderFormId),
{
orderItems,
},
Expand Down
79 changes: 79 additions & 0 deletions node/resolvers/utils/priceTokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { splitEvery, uniq, unnest } from 'ramda'

import { CATALOG_SEARCH_PAGE_SIZE } from '../../constants'

export const priceTokenKey = (skuId: string, seller: string) =>
`${skuId}-${seller}`

/**
* Fetches the signed prices (`priceToken`) for the given SKUs.
*
* The `useQuote` flow applies items stored in Master Data, so there is no live
* search to reuse a token from - we have to ask the catalog search for a fresh
* one at the moment the quote is applied. Sending the token to
* `PATCH /orderForm/{id}/items` lets Checkout build the cart even when Pricing
* is unavailable. `useQuote` calls that route twice: once without `index` to add
* the items, then again with `index` to overwrite the price with the negotiated
* one - so the token never changes the final price charged.
*
* The token is optional by design: it is only used when Pricing is down, it is
* behind a feature flag on the search API, and this whole call is a resilience
* improvement. Any failure is logged and ignored, keeping the previous behavior
* of adding items without a token.
*
* The field is `PriceToken` (PascalCase) here because this reads the Catalog
* Search REST API. Do not switch to the camelCase `priceToken`: that name only
* exists in `vtex.search-graphql`, which maps the REST field - reading
* PascalCase from a search-graphql query is a known source of bugs.
*
* The token is a JWT signed by `session/data-signer`, valid for 30 minutes,
* whose claims bind the price to `{ id, seller, accountName, salesChannel }` -
* hence the sales channel must be forwarded to the search, so the token is not
* bound to a channel other than the one the item is added on. When the quote
* carries no sales channel, the search resolves it to the account default, the
* same one `addToCart` falls back to.
*/
export const getPriceTokens = async (
ctx: Context,
{ skuIds, salesChannel }: { skuIds: string[]; salesChannel?: string | null }
): Promise<Record<string, string>> => {
const {
clients: { catalog },
vtex: { logger },
} = ctx

const priceTokens: Record<string, string> = {}

if (!skuIds.length) return priceTokens

try {
const batches = splitEvery(CATALOG_SEARCH_PAGE_SIZE, uniq(skuIds))

const products = unnest(
await Promise.all(
batches.map((batch) => catalog.searchBySkuIds(batch, salesChannel))
)
)

for (const product of products) {
for (const item of product?.items ?? []) {
for (const seller of item?.sellers ?? []) {
const priceToken = seller?.commertialOffer?.PriceToken

if (priceToken) {
priceTokens[
priceTokenKey(item.itemId, seller.sellerId)
] = priceToken
}
}
}
}
} catch (error) {
logger.warn({
error,
message: 'getPriceTokens-catalogSearchError',
})
}

return priceTokens
}
25 changes: 25 additions & 0 deletions node/typings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,28 @@ interface Seller {
id: string
name: string
}

interface CatalogCommertialOffer {
Price: number
ListPrice: number
// Signed price generated per search request, valid for 30 minutes. Only
// returned by accounts where the price signing feature flag is enabled.
// PascalCase is the name used by the Catalog Search REST API; the camelCase
// `priceToken` only exists in `vtex.search-graphql`.
PriceToken?: string
}

interface CatalogSeller {
sellerId: string
commertialOffer: CatalogCommertialOffer
}

interface CatalogItem {
itemId: string
sellers: CatalogSeller[]
}

interface CatalogProduct {
productId: string
items: CatalogItem[]
}
8 changes: 8 additions & 0 deletions node/utils/Hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export default class RequestHub extends ExternalClient {
})
}

// Unlike the methods above there is no `patchRaw`, so this resolves to the
// response body instead of the whole response.
public patch(url: string, data: any, headers?: any) {
return this.http.patch<any>(url, data, {
headers,
})
}

public delete(url: string) {
return this.http.delete(url)
}
Expand Down
10 changes: 5 additions & 5 deletions node/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -591,10 +591,10 @@
resolved "https://registry.yarnpkg.com/@types/shimmer/-/shimmer-1.2.0.tgz#9b706af96fa06416828842397a70dfbbf1c14ded"
integrity sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==

"@vtex/api@6.50.1":
version "6.50.1"
resolved "https://registry.yarnpkg.com/@vtex/api/-/api-6.50.1.tgz#a86578982a7aac7c7a8df2b9ec3df18bc43f01f2"
integrity sha512-4IlmYwCXKpkdpN2KN6NkPuRwnjet3ilSoET3PBOTTdZqE/mnuvIxRZRaQy+Yp7Gxu0XKAVdp/8SQJhsJaa6Unw==
"@vtex/api@6.51.0":
version "6.51.0"
resolved "https://registry.yarnpkg.com/@vtex/api/-/api-6.51.0.tgz#97aeb306619ff49fd890595b90568883eaf77d83"
integrity sha512-vRWKB4G1FPPt67rwWx+xGLanuP5y+M9SVmbKu3PzlTrqgq/aW/mINSUGa/Nhm64UBqIQCkVic7/smZ7kKFq52w==
dependencies:
"@types/koa" "^2.11.0"
"@types/koa-compose" "^3.2.3"
Expand Down Expand Up @@ -2254,7 +2254,7 @@ sprintf-js@~1.0.2:
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=

stats-lite@vtex/node-stats-lite#dist:
"stats-lite@github:vtex/node-stats-lite#dist":
version "2.2.1"
resolved "https://codeload.github.com/vtex/node-stats-lite/tar.gz/a0b5ee91861f31b6ec845146b4906faf5172c430"
dependencies:
Expand Down