From bf6d8780e9d17b0aae53a64ab72495339322f6a3 Mon Sep 17 00:00:00 2001 From: Wender Lima Date: Thu, 30 Jul 2026 10:45:29 -0400 Subject: [PATCH 1/8] feat: send priceToken on addToCart in useQuote [B2BTEAM-3733] The `useQuote` mutation applies quote items stored in Master Data, so there is no live search response to reuse a `PriceToken` from. Fetch a fresh signed price from the catalog search at the moment the quote is applied and forward it to `POST /orderForm/{id}/items`, so Checkout can build the cart even while Pricing is unavailable. The negotiated price keeps being applied afterwards through `PUT /orderForm/{id}/items/update`, so the token never changes the price charged. The token is optional by design (the field is behind a feature flag on the search API): any failure is logged and the items are added exactly as before. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 ++ manifest.json | 7 +++ node/clients/catalog.ts | 55 ++++++++++++++++++++++++ node/clients/index.ts | 5 +++ node/constants.ts | 3 ++ node/resolvers/mutations/index.ts | 15 ++++++- node/resolvers/utils/priceTokens.ts | 66 +++++++++++++++++++++++++++++ node/typings.d.ts | 23 ++++++++++ 8 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 node/clients/catalog.ts create mode 100644 node/resolvers/utils/priceTokens.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 581eed2..7dd057c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ 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. + ## [4.0.6] - 2026-03-10 ### Fixed diff --git a/manifest.json b/manifest.json index 5bb927c..e16b12e 100644 --- a/manifest.json +++ b/manifest.json @@ -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": { diff --git a/node/clients/catalog.ts b/node/clients/catalog.ts new file mode 100644 index 0000000..eac27c9 --- /dev/null +++ b/node/clients/catalog.ts @@ -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( + `${SEARCH_ENDPOINT}?${filters}${salesChannelQueryString}${pagination}`, + { + metric, + tracing: createTracing(metric, tracingConfig), + } + ) + } +} diff --git a/node/clients/index.ts b/node/clients/index.ts index f341576..36d4256 100644 --- a/node/clients/index.ts +++ b/node/clients/index.ts @@ -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' @@ -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) } diff --git a/node/constants.ts b/node/constants.ts index 9c834e6..ed746e9 100644 --- a/node/constants.ts +++ b/node/constants.ts @@ -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', diff --git a/node/resolvers/mutations/index.ts b/node/resolvers/mutations/index.ts index 37c1c01..bb7cbd1 100644 --- a/node/resolvers/mutations/index.ts +++ b/node/resolvers/mutations/index.ts @@ -24,6 +24,7 @@ import { checkQuoteStatus, checkSession, } from '../utils/checkPermissions' +import { getPriceTokens, priceTokenKey } from '../utils/priceTokens' import { createItemComparator, createQuoteObject, @@ -563,13 +564,25 @@ 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 + }) + // ADD ITEMS TO CART const data = await hub .post( `${routes.addToCart(account, orderFormId)}${salesChannelQueryString}`, { expectedOrderFormSections: ['items'], - orderItems: mergedItems, + orderItems: orderItemsToAdd, } ) .then((res: any) => { diff --git a/node/resolvers/utils/priceTokens.ts b/node/resolvers/utils/priceTokens.ts new file mode 100644 index 0000000..df6eb15 --- /dev/null +++ b/node/resolvers/utils/priceTokens.ts @@ -0,0 +1,66 @@ +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 + * `POST /orderForm/{id}/items` lets Checkout add the items even when Pricing is + * unavailable. The negotiated price is still applied afterwards through + * `PUT /orderForm/{id}/items/update`, so the token never changes the final + * price charged. + * + * The token is optional by design: the field 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. + */ +export const getPriceTokens = async ( + ctx: Context, + { skuIds, salesChannel }: { skuIds: string[]; salesChannel?: string | null } +): Promise> => { + const { + clients: { catalog }, + vtex: { logger }, + } = ctx + + const priceTokens: Record = {} + + 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 +} diff --git a/node/typings.d.ts b/node/typings.d.ts index 3a85eba..dc535e7 100644 --- a/node/typings.d.ts +++ b/node/typings.d.ts @@ -161,3 +161,26 @@ 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 Pricing Fallback feature flag is enabled. + PriceToken?: string +} + +interface CatalogSeller { + sellerId: string + commertialOffer: CatalogCommertialOffer +} + +interface CatalogItem { + itemId: string + sellers: CatalogSeller[] +} + +interface CatalogProduct { + productId: string + items: CatalogItem[] +} From 225eb5de86acaf4fb5e1a7a6e6233184ec5afae5 Mon Sep 17 00:00:00 2001 From: Wender Lima Date: Wed, 5 Aug 2026 15:32:00 -0400 Subject: [PATCH 2/8] chore: align priceToken reading with the Pricing Fallback thread [B2BTEAM-3733] - Accept both casings: the raw search API returns `PriceToken` (PascalCase), while `search-graphql@0.72.0`/`search-resolver@1.106.0` expose the same value as `priceToken`. - Log price token coverage on `useQuote`, mirroring the add-to-cart with/without token instrumentation the other storefronts are adding so the feature flag rollout can be followed from this app too. Co-Authored-By: Claude Opus 5 --- node/resolvers/mutations/index.ts | 10 ++++++++++ node/resolvers/utils/priceTokens.ts | 16 +++++++++++----- node/typings.d.ts | 3 +++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/node/resolvers/mutations/index.ts b/node/resolvers/mutations/index.ts index bb7cbd1..38596f2 100644 --- a/node/resolvers/mutations/index.ts +++ b/node/resolvers/mutations/index.ts @@ -576,6 +576,16 @@ export const Mutation = { return priceToken ? { ...item, priceToken } : item }) + // Tracks how often the fallback is actually available, so the rollout of + // the feature flag on the search API can be followed from this app too. + logger.info({ + message: 'useQuote-priceTokenCoverage', + itemsWithPriceToken: orderItemsToAdd.filter( + (item) => 'priceToken' in item + ).length, + totalItems: orderItemsToAdd.length, + }) + // ADD ITEMS TO CART const data = await hub .post( diff --git a/node/resolvers/utils/priceTokens.ts b/node/resolvers/utils/priceTokens.ts index df6eb15..f3aa07d 100644 --- a/node/resolvers/utils/priceTokens.ts +++ b/node/resolvers/utils/priceTokens.ts @@ -16,10 +16,14 @@ export const priceTokenKey = (skuId: string, seller: string) => * `PUT /orderForm/{id}/items/update`, so the token never changes the final * price charged. * - * The token is optional by design: the field 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 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 raw search API returns the field as `PriceToken` (PascalCase), while + * `search-graphql` exposes it as `priceToken`. Both are accepted so the reader + * does not depend on which one answers the request. */ export const getPriceTokens = async ( ctx: Context, @@ -46,7 +50,9 @@ export const getPriceTokens = async ( for (const product of products) { for (const item of product?.items ?? []) { for (const seller of item?.sellers ?? []) { - const priceToken = seller?.commertialOffer?.PriceToken + const { commertialOffer } = seller ?? {} + const priceToken = + commertialOffer?.PriceToken ?? commertialOffer?.priceToken if (priceToken) { priceTokens[priceTokenKey(item.itemId, seller.sellerId)] = diff --git a/node/typings.d.ts b/node/typings.d.ts index dc535e7..c200f61 100644 --- a/node/typings.d.ts +++ b/node/typings.d.ts @@ -167,7 +167,10 @@ interface CatalogCommertialOffer { ListPrice: number // Signed price generated per search request, valid for 30 minutes. Only // returned by accounts where the Pricing Fallback feature flag is enabled. + // The raw search API returns it as `PriceToken`; `search-graphql` exposes the + // same value as `priceToken`. PriceToken?: string + priceToken?: string } interface CatalogSeller { From 6494209797b3646e9eeb522660bbae767c7468a8 Mon Sep 17 00:00:00 2001 From: Wender Lima Date: Thu, 6 Aug 2026 14:37:58 -0400 Subject: [PATCH 3/8] chore: keep priceToken reading on the REST PascalCase field [B2BTEAM-3733] Validated on b2bstoreqa (price signing flag enabled): the Catalog Search REST API returns `PriceToken` (PascalCase) and the token is a JWT signed by `session/data-signer`, valid for 30 minutes, whose claims bind the price to `{ id, seller, accountName, salesChannel }`. - Drop the camelCase `priceToken` alias added earlier: that name only exists in `vtex.search-graphql`, which maps the REST field, so it is unreachable here and the typing was asserting a field the REST response does not have. - Document why the sales channel must be forwarded to the search (it is part of the claims) and that it resolves to the account default when the quote has none - the same default `addToCart` falls back to. - Log the sales channel alongside the coverage counters. Co-Authored-By: Claude Opus 5 --- node/resolvers/mutations/index.ts | 7 +++++-- node/resolvers/utils/priceTokens.ts | 18 ++++++++++++------ node/typings.d.ts | 7 +++---- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/node/resolvers/mutations/index.ts b/node/resolvers/mutations/index.ts index 38596f2..e5c5f62 100644 --- a/node/resolvers/mutations/index.ts +++ b/node/resolvers/mutations/index.ts @@ -576,14 +576,17 @@ export const Mutation = { return priceToken ? { ...item, priceToken } : item }) - // Tracks how often the fallback is actually available, so the rollout of - // the feature flag on the search API can be followed from this app too. + // 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 diff --git a/node/resolvers/utils/priceTokens.ts b/node/resolvers/utils/priceTokens.ts index f3aa07d..e74cdea 100644 --- a/node/resolvers/utils/priceTokens.ts +++ b/node/resolvers/utils/priceTokens.ts @@ -21,9 +21,17 @@ export const priceTokenKey = (skuId: string, seller: string) => * improvement. Any failure is logged and ignored, keeping the previous behavior * of adding items without a token. * - * The raw search API returns the field as `PriceToken` (PascalCase), while - * `search-graphql` exposes it as `priceToken`. Both are accepted so the reader - * does not depend on which one answers the request. + * 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, @@ -50,9 +58,7 @@ export const getPriceTokens = async ( for (const product of products) { for (const item of product?.items ?? []) { for (const seller of item?.sellers ?? []) { - const { commertialOffer } = seller ?? {} - const priceToken = - commertialOffer?.PriceToken ?? commertialOffer?.priceToken + const priceToken = seller?.commertialOffer?.PriceToken if (priceToken) { priceTokens[priceTokenKey(item.itemId, seller.sellerId)] = diff --git a/node/typings.d.ts b/node/typings.d.ts index c200f61..788f2c6 100644 --- a/node/typings.d.ts +++ b/node/typings.d.ts @@ -166,11 +166,10 @@ interface CatalogCommertialOffer { Price: number ListPrice: number // Signed price generated per search request, valid for 30 minutes. Only - // returned by accounts where the Pricing Fallback feature flag is enabled. - // The raw search API returns it as `PriceToken`; `search-graphql` exposes the - // same value as `priceToken`. + // 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 - priceToken?: string } interface CatalogSeller { From ce565532d4488a817682c6faa4c7796611427f4c Mon Sep 17 00:00:00 2001 From: Wender Lima Date: Fri, 7 Aug 2026 15:00:45 -0400 Subject: [PATCH 4/8] style: fix prettier formatting in priceTokens [B2BTEAM-3733] Co-Authored-By: Claude Opus 5 --- node/package.json | 2 +- node/resolvers/utils/priceTokens.ts | 5 +++-- node/yarn.lock | 10 +++++----- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/node/package.json b/node/package.json index a7ce624..5f35e6b 100644 --- a/node/package.json +++ b/node/package.json @@ -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", diff --git a/node/resolvers/utils/priceTokens.ts b/node/resolvers/utils/priceTokens.ts index e74cdea..4afd48c 100644 --- a/node/resolvers/utils/priceTokens.ts +++ b/node/resolvers/utils/priceTokens.ts @@ -61,8 +61,9 @@ export const getPriceTokens = async ( const priceToken = seller?.commertialOffer?.PriceToken if (priceToken) { - priceTokens[priceTokenKey(item.itemId, seller.sellerId)] = - priceToken + priceTokens[ + priceTokenKey(item.itemId, seller.sellerId) + ] = priceToken } } } diff --git a/node/yarn.lock b/node/yarn.lock index aee47d4..9798802 100644 --- a/node/yarn.lock +++ b/node/yarn.lock @@ -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" @@ -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: From 29f90a32a3f248942ad04e2d4990a5e6445de1b1 Mon Sep 17 00:00:00 2001 From: Wender Lima Date: Fri, 7 Aug 2026 15:01:19 -0400 Subject: [PATCH 5/8] revert: drop unintended @vtex/api bump [B2BTEAM-3733] A local install bumped @vtex/api from 6.50.1 to 6.51.0 and it rode along in the previous commit. Unrelated to this PR, reverted to match master. Co-Authored-By: Claude Opus 5 --- node/package.json | 2 +- node/yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/node/package.json b/node/package.json index 5f35e6b..a7ce624 100644 --- a/node/package.json +++ b/node/package.json @@ -9,7 +9,7 @@ "ramda": "^0.25.0", "atob": "^2.1.2", "axios": "0.27.2", - "@vtex/api": "6.51.0" + "@vtex/api": "6.50.1" }, "devDependencies": { "@types/atob": "^2.1.2", diff --git a/node/yarn.lock b/node/yarn.lock index 9798802..aee47d4 100644 --- a/node/yarn.lock +++ b/node/yarn.lock @@ -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.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== +"@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== dependencies: "@types/koa" "^2.11.0" "@types/koa-compose" "^3.2.3" @@ -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@github:vtex/node-stats-lite#dist": +stats-lite@vtex/node-stats-lite#dist: version "2.2.1" resolved "https://codeload.github.com/vtex/node-stats-lite/tar.gz/a0b5ee91861f31b6ec845146b4906faf5172c430" dependencies: From 96c0c953448a628f6e792d84e57cef76402880e8 Mon Sep 17 00:00:00 2001 From: Wender Lima Date: Fri, 7 Aug 2026 15:54:43 -0400 Subject: [PATCH 6/8] fix: add quote items with PATCH so priceToken is honored [B2BTEAM-3733] Confirmed by Schirmer in the Pricing Fallback thread: `POST /orderForm/{id}/items` does not honor `priceToken` - only `PATCH` does - and POST is no longer meant to be used at all. Sending the token on POST would have been silently ignored, since nothing in the response reports whether the token was consumed. Items keep being added as new items: `PATCH` only updates existing ones when an `index` is sent, which this call never does. The cart is cleared right before, so there is nothing to update anyway. `RequestHub` gains a `patch` method. Note there is no `patchRaw` in @vtex/api, so it resolves to the response body and the call site no longer unwraps `.data`. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 ++++ node/constants.ts | 2 ++ node/resolvers/mutations/index.ts | 22 +++++++++++----------- node/resolvers/utils/priceTokens.ts | 2 +- node/utils/Hub.ts | 8 ++++++++ 5 files changed, 26 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dd057c..443d859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [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 adds the quote items to the cart with `PATCH /orderForm/{id}/items` instead of `POST`. Only `PATCH` honors `priceToken`, and `POST` is no longer meant to be used. Items are still added as new items, since no `index` is sent. + ## [4.0.6] - 2026-03-10 ### Fixed diff --git a/node/constants.ts b/node/constants.ts index ed746e9..00c45a9 100644 --- a/node/constants.ts +++ b/node/constants.ts @@ -50,6 +50,8 @@ export const routes = { }`, addPriceToItems: (account: string, orderFormId: string) => `${routes.orderForm(account)}/${orderFormId}/items/update`, + // Must be called with PATCH: POST does not honor `priceToken` and is no + // longer meant to be used. addToCart: (account: string, orderFormId: string) => `${routes.orderForm(account)}/${orderFormId}/items/`, baseUrl: (account: string) => diff --git a/node/resolvers/mutations/index.ts b/node/resolvers/mutations/index.ts index e5c5f62..836a7c0 100644 --- a/node/resolvers/mutations/index.ts +++ b/node/resolvers/mutations/index.ts @@ -590,17 +590,17 @@ export const Mutation = { }) // ADD ITEMS TO CART - const data = await hub - .post( - `${routes.addToCart(account, orderFormId)}${salesChannelQueryString}`, - { - expectedOrderFormSections: ['items'], - orderItems: orderItemsToAdd, - } - ) - .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.addToCart(account, orderFormId)}${salesChannelQueryString}`, + { + expectedOrderFormSections: ['items'], + orderItems: orderItemsToAdd, + } + ) const { items: itemsAdded } = data diff --git a/node/resolvers/utils/priceTokens.ts b/node/resolvers/utils/priceTokens.ts index 4afd48c..8cec2fd 100644 --- a/node/resolvers/utils/priceTokens.ts +++ b/node/resolvers/utils/priceTokens.ts @@ -11,7 +11,7 @@ export const priceTokenKey = (skuId: string, seller: string) => * 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 - * `POST /orderForm/{id}/items` lets Checkout add the items even when Pricing is + * `PATCH /orderForm/{id}/items` lets Checkout add the items even when Pricing is * unavailable. The negotiated price is still applied afterwards through * `PUT /orderForm/{id}/items/update`, so the token never changes the final * price charged. diff --git a/node/utils/Hub.ts b/node/utils/Hub.ts index 0a5d9fa..e6a7b67 100644 --- a/node/utils/Hub.ts +++ b/node/utils/Hub.ts @@ -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(url, data, { + headers, + }) + } + public delete(url: string) { return this.http.delete(url) } From 52fb2f55781be085b731656356db94763ac21645 Mon Sep 17 00:00:00 2001 From: Wender Lima Date: Fri, 7 Aug 2026 17:27:20 -0400 Subject: [PATCH 7/8] fix: apply the negotiated price with PATCH too [B2BTEAM-3733] Schirmer checked `POST /orderForm/{id}/items/update` and it does not honor `priceToken` either, and confirmed PATCH /items is meant to be the only route used. That matters here: with the price override still on the old route, a Pricing outage would break `useQuote` at the second call even though the items had already made it into the cart with a signed price. Both cart operations now go through `PATCH /orderForm/{id}/items`: adding the items (no `index`) and overwriting the price with the negotiated one (`index`). The price payload gains `id` and `seller`, which PATCH requires and the old route did not, and it carries `priceToken` as well. Since both calls now hit the same URL, `routes.addToCart` and `routes.addPriceToItems` collapse into a single `routes.cartItems`, normalized to the documented path without the trailing slash. Also fixes comments that described the price override as `PUT /items/update`. It was a POST - the wrong verb came from the ticket description. --- CHANGELOG.md | 2 +- node/constants.ts | 12 ++++++------ node/resolvers/mutations/index.ts | 17 ++++++++++++++--- node/resolvers/utils/priceTokens.ts | 8 ++++---- 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 443d859..9d24875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- [B2BTEAM-3733] `useQuote` now adds the quote items to the cart with `PATCH /orderForm/{id}/items` instead of `POST`. Only `PATCH` honors `priceToken`, and `POST` is no longer meant to be used. Items are still added as new items, since no `index` is sent. +- [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 diff --git a/node/constants.ts b/node/constants.ts index 00c45a9..aa14cff 100644 --- a/node/constants.ts +++ b/node/constants.ts @@ -48,12 +48,12 @@ export const routes = { `${routes.orderForm(account)}/${orderFormId}/customData/${appId}/${ property ?? '' }`, - addPriceToItems: (account: string, orderFormId: string) => - `${routes.orderForm(account)}/${orderFormId}/items/update`, - // Must be called with PATCH: POST does not honor `priceToken` and is no - // longer meant to be used. - 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) => diff --git a/node/resolvers/mutations/index.ts b/node/resolvers/mutations/index.ts index 836a7c0..d01f46d 100644 --- a/node/resolvers/mutations/index.ts +++ b/node/resolvers/mutations/index.ts @@ -595,7 +595,7 @@ export const Mutation = { // 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.addToCart(account, orderFormId)}${salesChannelQueryString}`, + `${routes.cartItems(account, orderFormId)}${salesChannelQueryString}`, { expectedOrderFormSections: ['items'], orderItems: orderItemsToAdd, @@ -625,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, }, diff --git a/node/resolvers/utils/priceTokens.ts b/node/resolvers/utils/priceTokens.ts index 8cec2fd..5cd65aa 100644 --- a/node/resolvers/utils/priceTokens.ts +++ b/node/resolvers/utils/priceTokens.ts @@ -11,10 +11,10 @@ export const priceTokenKey = (skuId: string, seller: string) => * 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 add the items even when Pricing is - * unavailable. The negotiated price is still applied afterwards through - * `PUT /orderForm/{id}/items/update`, so the token never changes the final - * price charged. + * `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 From ce5cc35cb37de0a4cb4686f472a2d85f50559ab5 Mon Sep 17 00:00:00 2001 From: Wender Lima Date: Mon, 10 Aug 2026 13:12:15 -0400 Subject: [PATCH 8/8] Git ignore; dependency update --- .gitignore | 4 +++- node/package.json | 2 +- node/yarn.lock | 10 +++++----- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index d88040a..7770294 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,6 @@ node/node_modules react/package-lock.json node/package-lock.json node/node_modules -.vscode/ \ No newline at end of file +.vscode/ +.claude +.cursor \ No newline at end of file diff --git a/node/package.json b/node/package.json index a7ce624..5f35e6b 100644 --- a/node/package.json +++ b/node/package.json @@ -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", diff --git a/node/yarn.lock b/node/yarn.lock index aee47d4..9798802 100644 --- a/node/yarn.lock +++ b/node/yarn.lock @@ -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" @@ -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: