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/CHANGELOG.md b/CHANGELOG.md index 581eed2..9d24875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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..aa14cff 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', @@ -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) => 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/mutations/index.ts b/node/resolvers/mutations/index.ts index 37c1c01..d01f46d 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,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 @@ -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, }, diff --git a/node/resolvers/utils/priceTokens.ts b/node/resolvers/utils/priceTokens.ts new file mode 100644 index 0000000..5cd65aa --- /dev/null +++ b/node/resolvers/utils/priceTokens.ts @@ -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> => { + 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..788f2c6 100644 --- a/node/typings.d.ts +++ b/node/typings.d.ts @@ -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[] +} 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) } 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: