Skip to content

[B2BTEAM-3748] Forward priceToken on addToCart (Pricing Fallback V2) - #17

Open
wender wants to merge 2 commits into
masterfrom
feature/B2BTEAM-3748_forward-price-token-on-add-to-cart
Open

[B2BTEAM-3748] Forward priceToken on addToCart (Pricing Fallback V2)#17
wender wants to merge 2 commits into
masterfrom
feature/B2BTEAM-3748_forward-price-token-on-add-to-cart

Conversation

@wender

@wender wender commented Jul 30, 2026

Copy link
Copy Markdown

What does this PR do? *

Pricing Fallback V2 (B2BTEAM-3748): captures the signed price returned by the search — exposed as commertialOffer.priceToken and read here from vtex.product-context — and forwards it as priceToken in the addToCart payload, so the Checkout can close the cart with that price even while the Pricing is unavailable.

  • catalogItemToCart.ts reads the token from the seller of the SKU being added and returns it as priceToken on the mapped cart item.
  • AddToCartButton.tsx (adjustItemsForMutationInput) sends priceToken in the vtex.checkout-resources mutation body, alongside id/index/seller/quantity/options.
  • CommercialOffer gains optional priceToken/PriceToken, OrderFormItemInput an optional priceToken.
  • Unit tests for the token resolution in mapCatalogItemToCart.

Three deliberate choices:

  • The field is read as priceToken (camelCase), with PriceToken as fallback. vtex.search-graphql@0.72.0 / search-resolver@1.106.0 (both deployed 2026-07-20) expose it on the Offer type as priceToken; the PascalCase spelling is the one the raw Catalog Search API returns, which search-resolver maps from. As this app reads the offer through vtex.product-context, the camelCase name is the one that actually arrives, and the fallback covers Catalog-shaped data.
  • The token is read from the seller and the SKU that are actually sent. BuyButton/Wrapper.tsx resolves selectedSeller from productContext.selectedItem.sellers[0], while the item sent to the cart is built from the selectedItem prop — which, when the button is rendered per row by SkuBuyButton, is that row's SKU, not the product context one. So instead of reading the token straight off selectedSeller, it is looked up inside selectedItem.sellers by the already resolved sellerId. This way the token can never sign the price of a different SKU/seller than the one added to the cart (if no match is found, no token is sent). The pre-existing seller/price/listPrice resolution is left untouched.
  • priceToken is only added to the payload when the search returns one (conditional spread), per the product decision that the token is optional and only used during a Pricing incident.

How to test it? *

Requires an account with price signing enabled on Intelligent Search (still behind a feature flag — only test stores such as storeframework; the search team enables it on request), and a theme whose product query requests commertialOffer { priceToken }, since this app only consumes vtex.product-context and does not own the query.

  1. vtex link the app and open a page with sku-list and its buy button.
  2. Inspect the product query response — items[].sellers[].commertialOffer.priceToken should be present.
  3. Add a SKU to the cart and inspect the addToCart mutation payload — the item should carry priceToken alongside id, index, seller, quantity and options.
  4. With a multi-SKU list, add a different row and confirm the token belongs to that row's SKU/seller (or is absent, rather than borrowed from another SKU).
  5. Regression: on an account/theme without the field exposed, add to cart must keep working with no priceToken in the payload.
  6. cd react && yarn test — the mapCatalogItemToCart specs cover both field spellings, token absent, and seller mismatch.

End-to-end validation of the fallback itself can only be done by intentionally opening the circuit with the Pricing, as agreed in the reference thread.

Describe alternatives you've considered, if any. *

  • Reading the token directly from selectedSeller. Simpler, but on the per-row SkuBuyButton usage selectedSeller comes from the product context item while the SKU sent comes from the row, so it could sign another SKU's price.
  • Fixing the selectedSeller resolution in Wrapper.tsx to always derive from the selectedItem prop. That would also correct price/listPrice/seller for the per-row case, but it is a behaviour change well beyond this ticket and deserves its own investigation.

Related to / Depends on *

Blocked — do not merge yet. ItemInput on vtex.checkout-graphql (master, 0.68.0) still has no priceToken field, and vtex.checkout-resources declares the mutation as $items: [ItemInput]. Sending an unknown input field makes GraphQL reject the variable, so on an account where the token is present this would break add to cart. This can only be merged after the Checkout Experience team adds the field — their spike was scheduled for the sprint starting 2026-08-03. We opened checkout-graphql#219 proposing exactly that field, as a starting point for them.

Once the field exists, the token reaches checkout through the verb that honours it: this app's mutation goes addToCartcheckout.addItemPATCH /orderForm/{id}/items. Guilherme Schirmer confirmed in the thread that only PATCH honours priceTokenPOST /items ignores it (and is no longer meant to be used), so any integration adding items over POST would drop the token silently. Not a concern here, but worth knowing for anyone reusing this pattern outside the GraphQL path.

The index: 0 this app sends is also not a risk of overwriting cart lines: the checkout-graphql resolver strips index (along with options and uniqueId) before building the REST payload, and an item without index is what makes PATCH add instead of update.

Confirmed at runtime on b2bstoreqa (price signing enabled, search-graphql@0.72.0, product query patched to request the field):

GraphQL error: Variable "$items" got invalid value
{ id: 1, index: 0, seller: "1", quantity: 1, options: [], priceToken: "eyJhbGciOiJFUzI1NiIs…" }
at "items[0]"; Field "priceToken" is not defined by type ItemInput.

The token itself was correct — claims {"price":390,"seller":"1","id":"1","accountName":"b2bstoreqa","salesChannel":"1"}, matching the SKU, seller and price of the row that was added, with the expected 30-minute window. So the forwarding works end to end and the only thing missing is the Checkout input field.

Status of the chain (per the reference thread):

Layer Status
Intelligent Search (intsch) price signing ✅ in production since 2026-07-15, behind a per-account feature flag (test stores only)
search-resolver / search-graphql 1.106.0 / 0.72.0, deployed 2026-07-20 — unblocks the prerequisite noted in the ticket
checkout-graphql (ItemInput.priceToken) ❌ pending — hard blocker for this PR; proposed in checkout-graphql#219
Product query exposing commertialOffer { priceToken } ❌ pending — proposed in store-resources#199; without it the token never reaches vtex.product-context and this PR stays inert

Merge order: checkout-graphql#219 → store-resources#199 → this PR. Inverting the last two would make consumers forward a token that the Checkout still rejects.

Other notes:

  • Sibling implementation for the same initiative: quickorder#185 (B2BTEAM-3732) — it needs the same field-name correction, since its queries request PriceToken from vtex.search-graphql, where the field is priceToken.
  • The token is valid for 30 minutes, cannot be renewed, and the search response is cached (~10 min API Cache + GraphQL cache), so part of the validity may already be consumed when it reaches the front end. By design an expired/absent token just falls back to the Pricing, so no extra handling here.
  • yarn lint already fails on master (prettier/prettier parsing errors on the optional chaining in SkuHighlights.tsx and SkuSpecifications.tsx, with the pinned Prettier 1.x). Unrelated to this PR — tsc --noEmit, the tests and ESLint on the touched files all pass.

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 <noreply@anthropic.com>
@vtex-io-docs-bot

Copy link
Copy Markdown

Beep boop 🤖

I noticed you didn't make any changes at the docs/ folder

  • There's nothing new to document 🤔
  • I'll do it later 😞

In order to keep track, I'll create an issue if you decide now is not a good time

  • I just updated 🎉🎉

@vtex-io-ci-cd

vtex-io-ci-cd Bot commented Jul 30, 2026

Copy link
Copy Markdown

Hi! I'm VTEX IO CI/CD Bot and I'll be helping you to publish your app! 🤖

Please select which version do you want to release:

  • Patch (backwards-compatible bug fixes)

  • Minor (backwards-compatible functionality)

  • Major (incompatible API changes)

And then you just need to merge your PR when you are ready! There is no need to create a release commit/tag.

  • No thanks, I would rather do it manually 😞

`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 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds support for forwarding a signed priceToken from the product-context commercial offer into the addToCart mutation payload, enabling Checkout to honor the searched price during Pricing incidents (Pricing Fallback V2).

Changes:

  • Extend typings to include optional CommercialOffer.priceToken/PriceToken and OrderFormItemInput.priceToken.
  • Resolve priceToken from the selected item’s matching seller in mapCatalogItemToCart.
  • Conditionally include priceToken in the addToCart mutation input and add unit tests for token resolution behavior.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
react/typings.ts Adds optional priceToken fields to offer + order form item input typings.
react/components/BuyButton/modules/catalogItemToCart.ts Resolves priceToken from the selected item’s seller commercial offer and returns it on mapped cart items.
react/components/BuyButton/AddToCartButton.tsx Conditionally forwards priceToken into the addToCart mutation variables.
react/tests/catalogItemToCart.test.ts Adds unit tests covering token resolution and seller matching behavior.
CHANGELOG.md Documents the new signed price token forwarding behavior under Unreleased.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +58 to 61
// 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 } : {}),
}))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants