From 080d053594efd9a37dfd96fa979e91a452e7f5a5 Mon Sep 17 00:00:00 2001 From: Wender Lima Date: Thu, 30 Jul 2026 10:57:08 -0400 Subject: [PATCH 1/2] [B2BTEAM-3748] Forward priceToken on addToCart Pricing Fallback V2: read the signed price (commertialOffer.PriceToken) from the product context and forward it as priceToken in the addToCart payload, so the Checkout can close the cart while the Pricing is unavailable. The token is read from the seller entry of the very SKU sent on addToCart, since it signs that seller's price for that item, and it is only added to the payload when the search actually returned one, keeping the payload unchanged while search-graphql/product-context do not expose the field yet. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 4 + react/__tests__/catalogItemToCart.test.ts | 75 +++++++++++++++++++ .../components/BuyButton/AddToCartButton.tsx | 3 + .../BuyButton/modules/catalogItemToCart.ts | 32 +++++++- react/typings.ts | 4 + 5 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 react/__tests__/catalogItemToCart.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f0df855..6e6f392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- Forward the signed price (`commertialOffer.PriceToken`) from the product context as `priceToken` on the `addToCart` payload, so the Checkout can close the cart with the signed price while the Pricing is unavailable (Pricing Fallback V2) + ## [1.3.2] - 2026-03-06 ### Changed diff --git a/react/__tests__/catalogItemToCart.test.ts b/react/__tests__/catalogItemToCart.test.ts new file mode 100644 index 0000000..aa29043 --- /dev/null +++ b/react/__tests__/catalogItemToCart.test.ts @@ -0,0 +1,75 @@ +import { mapCatalogItemToCart } from '../components/BuyButton/modules/catalogItemToCart' +import { Item, Product, Seller } from '../typings' + +const makeSeller = (sellerId: string, priceToken?: string): Seller => ({ + sellerId, + sellerName: `seller ${sellerId}`, + commertialOffer: { + Price: 10, + ListPrice: 12, + PriceWithoutDiscount: 12, + RewardValue: 0, + AvailableQuantity: 5, + Installments: [], + ...(priceToken ? { PriceToken: priceToken } : {}), + }, +}) + +const makeItem = (sellers: Seller[]): Item => ({ + itemId: '1', + name: 'SKU name', + measurementUnit: 'un', + unitMultiplier: 1, + images: [], + variations: [], + sellers, +}) + +const product: Product = { + productId: '1', + productName: 'Product name', + productReference: 'ref-1', + linkText: 'product-name', + brand: 'Brand', + brandId: 1, + items: [], + categories: ['/Category/'], +} + +const mapWith = (sellers: Seller[], selectedSeller: Seller) => + mapCatalogItemToCart({ + product, + selectedItem: makeItem(sellers), + quantity: 1, + selectedSeller, + })[0] + +describe('mapCatalogItemToCart priceToken', () => { + it('takes the price token of the seller sent on addToCart', () => { + const seller = makeSeller('1', 'signed-price-token') + + expect(mapWith([seller], seller).priceToken).toBe('signed-price-token') + }) + + it('leaves the price token undefined when the search does not return one', () => { + const seller = makeSeller('1') + + expect(mapWith([seller], seller).priceToken).toBeUndefined() + }) + + it('does not take the price token of another seller', () => { + const selectedSeller = makeSeller('1') + const otherSeller = makeSeller('2', 'other-seller-token') + + expect(mapWith([otherSeller], selectedSeller).priceToken).toBeUndefined() + }) + + it('takes the price token of the selected item, not of the product context item', () => { + const sellerOnSelectedItem = makeSeller('1', 'selected-item-token') + const sellerOnContextItem = makeSeller('1', 'context-item-token') + + expect( + mapWith([sellerOnSelectedItem], sellerOnContextItem).priceToken + ).toBe('selected-item-token') + }) +}) diff --git a/react/components/BuyButton/AddToCartButton.tsx b/react/components/BuyButton/AddToCartButton.tsx index 44faa68..453ffaf 100644 --- a/react/components/BuyButton/AddToCartButton.tsx +++ b/react/components/BuyButton/AddToCartButton.tsx @@ -55,6 +55,9 @@ const adjustItemsForMutationInput = ( seller: item.seller, quantity: item.quantity, options: item.options, + // Only sent when the search actually returned a token, keeping the payload + // unchanged while the field is not exposed by the product context yet + ...(item.priceToken ? { priceToken: item.priceToken } : {}), })) } diff --git a/react/components/BuyButton/modules/catalogItemToCart.ts b/react/components/BuyButton/modules/catalogItemToCart.ts index 116f80d..b35aa05 100644 --- a/react/components/BuyButton/modules/catalogItemToCart.ts +++ b/react/components/BuyButton/modules/catalogItemToCart.ts @@ -1,4 +1,4 @@ -import { path } from 'ramda' +import { find, path, pathOr, propEq } from 'ramda' import { transformAssemblyOptions, @@ -6,7 +6,13 @@ import { ParsedAssemblyOptionsMeta, Option, } from './assemblyOptions' -import { AssemblyOptionItem, Item, Maybe, Product } from '../../../typings' +import { + AssemblyOptionItem, + Item, + Maybe, + Product, + Seller, +} from '../../../typings' interface MapCatalogItemToCartArgs { product: Maybe @@ -34,6 +40,7 @@ export interface MapCatalogItemToCartReturn { variant: string skuId: string imageUrl: string | undefined + priceToken: string | undefined sellingPriceWithAssemblies: number options: Option[] assemblyOptions: ParsedAssemblyOptionsMeta @@ -46,6 +53,26 @@ export function mapCatalogItemToCart({ selectedSeller, assemblyOptions, }: MapCatalogItemToCartArgs): MapCatalogItemToCartReturn[] { + // Signed price from the search response, forwarded on addToCart so the + // Checkout can close the cart even while the Pricing is unavailable. It has + // to be read from the very SKU and seller sent on addToCart, since it signs + // that seller's price for that item + const sellersFromSelectedItem: Seller[] = pathOr( + [], + ['sellers'], + selectedItem + ) + + const signedSeller = find( + propEq('sellerId', selectedSeller && selectedSeller.sellerId), + sellersFromSelectedItem + ) + + const priceToken = path( + ['commertialOffer', 'PriceToken'], + signedSeller + ) + return ( product && selectedItem && @@ -68,6 +95,7 @@ export function mapCatalogItemToCart({ variant: selectedItem.name, skuId: selectedItem.itemId, imageUrl: path(['images', '0', 'imageUrl'], selectedItem), + priceToken, ...transformAssemblyOptions( path(['items'], assemblyOptions), path(['inputValues'], assemblyOptions), diff --git a/react/typings.ts b/react/typings.ts index 53ddad6..a3f8d9b 100644 --- a/react/typings.ts +++ b/react/typings.ts @@ -59,6 +59,9 @@ export interface CommercialOffer { RewardValue: number AvailableQuantity: number Installments: Installment[] + // Signed price generated by the search request that returned this offer. + // Optional because it is only present when the search exposes the field + PriceToken?: string } export interface Installment { @@ -416,4 +419,5 @@ export interface OrderFormItemInput { seller?: string uniqueId?: string options?: AssemblyOptionInput[] + priceToken?: string } From be520270a5a5ae56151403377ee363523f78f1de Mon Sep 17 00:00:00 2001 From: Wender Lima Date: Wed, 5 Aug 2026 14:47:39 -0400 Subject: [PATCH 2/2] Read the token as commertialOffer.priceToken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `vtex.search-graphql` 0.72.0 / `search-resolver` 1.106.0 (deployed 2026-07-20) expose the signed price on the `Offer` type as `priceToken`, not `PriceToken` — the PascalCase name is the one returned by the raw Catalog Search API, which `search-resolver` maps from. Since this app reads the offer through `vtex.product-context`, fed by search-graphql, it has to look for the camelCase field, keeping the Catalog spelling as a fallback for other data sources. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- react/__tests__/catalogItemToCart.test.ts | 28 ++++++++++++++----- .../BuyButton/modules/catalogItemToCart.ts | 9 +++--- react/typings.ts | 5 +++- 4 files changed, 31 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e6f392..cb13c86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added -- Forward the signed price (`commertialOffer.PriceToken`) from the product context as `priceToken` on the `addToCart` payload, so the Checkout can close the cart with the signed price while the Pricing is unavailable (Pricing Fallback V2) +- Forward the signed price (`commertialOffer.priceToken`) from the product context as `priceToken` on the `addToCart` payload, so the Checkout can close the cart with the signed price while the Pricing is unavailable (Pricing Fallback V2) ## [1.3.2] - 2026-03-06 diff --git a/react/__tests__/catalogItemToCart.test.ts b/react/__tests__/catalogItemToCart.test.ts index aa29043..8cedd4c 100644 --- a/react/__tests__/catalogItemToCart.test.ts +++ b/react/__tests__/catalogItemToCart.test.ts @@ -1,7 +1,10 @@ import { mapCatalogItemToCart } from '../components/BuyButton/modules/catalogItemToCart' import { Item, Product, Seller } from '../typings' -const makeSeller = (sellerId: string, priceToken?: string): Seller => ({ +const makeSeller = ( + sellerId: string, + token?: { priceToken?: string; PriceToken?: string } +): Seller => ({ sellerId, sellerName: `seller ${sellerId}`, commertialOffer: { @@ -11,7 +14,7 @@ const makeSeller = (sellerId: string, priceToken?: string): Seller => ({ RewardValue: 0, AvailableQuantity: 5, Installments: [], - ...(priceToken ? { PriceToken: priceToken } : {}), + ...token, }, }) @@ -45,12 +48,18 @@ const mapWith = (sellers: Seller[], selectedSeller: Seller) => })[0] describe('mapCatalogItemToCart priceToken', () => { - it('takes the price token of the seller sent on addToCart', () => { - const seller = makeSeller('1', 'signed-price-token') + it('takes the token exposed by search-graphql as `priceToken`', () => { + const seller = makeSeller('1', { priceToken: 'signed-price-token' }) expect(mapWith([seller], seller).priceToken).toBe('signed-price-token') }) + it('falls back to the Catalog Search API `PriceToken`', () => { + const seller = makeSeller('1', { PriceToken: 'catalog-price-token' }) + + expect(mapWith([seller], seller).priceToken).toBe('catalog-price-token') + }) + it('leaves the price token undefined when the search does not return one', () => { const seller = makeSeller('1') @@ -59,14 +68,19 @@ describe('mapCatalogItemToCart priceToken', () => { it('does not take the price token of another seller', () => { const selectedSeller = makeSeller('1') - const otherSeller = makeSeller('2', 'other-seller-token') + const otherSeller = makeSeller('2', { priceToken: 'other-seller-token' }) expect(mapWith([otherSeller], selectedSeller).priceToken).toBeUndefined() }) it('takes the price token of the selected item, not of the product context item', () => { - const sellerOnSelectedItem = makeSeller('1', 'selected-item-token') - const sellerOnContextItem = makeSeller('1', 'context-item-token') + const sellerOnSelectedItem = makeSeller('1', { + priceToken: 'selected-item-token', + }) + + const sellerOnContextItem = makeSeller('1', { + priceToken: 'context-item-token', + }) expect( mapWith([sellerOnSelectedItem], sellerOnContextItem).priceToken diff --git a/react/components/BuyButton/modules/catalogItemToCart.ts b/react/components/BuyButton/modules/catalogItemToCart.ts index b35aa05..db5137c 100644 --- a/react/components/BuyButton/modules/catalogItemToCart.ts +++ b/react/components/BuyButton/modules/catalogItemToCart.ts @@ -68,10 +68,11 @@ export function mapCatalogItemToCart({ sellersFromSelectedItem ) - const priceToken = path( - ['commertialOffer', 'PriceToken'], - signedSeller - ) + // `vtex.search-graphql` exposes it as `priceToken` (>= 0.72.0), while the raw + // Catalog Search API returns it as `PriceToken` + const priceToken: string | undefined = + path(['commertialOffer', 'priceToken'], signedSeller) || + path(['commertialOffer', 'PriceToken'], signedSeller) return ( product && diff --git a/react/typings.ts b/react/typings.ts index a3f8d9b..99dcdc3 100644 --- a/react/typings.ts +++ b/react/typings.ts @@ -60,7 +60,10 @@ export interface CommercialOffer { AvailableQuantity: number Installments: Installment[] // Signed price generated by the search request that returned this offer. - // Optional because it is only present when the search exposes the field + // Optional because it is only present when price signing is enabled for the + // account. `vtex.search-graphql` exposes it as `priceToken` (>= 0.72.0), + // while the raw Catalog Search API returns it as `PriceToken` + priceToken?: string PriceToken?: string }