[B2BTEAM-3732] Forward priceToken on addToCart (Pricing Fallback V2) - #185
Conversation
Pricing Fallback V2: fetch the signed price (commertialOffer.PriceToken) in the search-graphql product queries and forward it as priceToken in the addToCart payload, so the Checkout can close the cart while the Pricing is unavailable. Covers AutocompleteBlock and CategoryBlock. TextAreaBlock and UploadBlock resolve SKUs through the app's own skuFromRefIds resolver, which has no access to the token, and are left for a follow-up. The token is only added to the payload when the search actually returns one, keeping the payload unchanged while the field is not exposed yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
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:
And then you just need to merge your PR when you are ready! There is no need to create a release commit/tag.
|
|
Beep boop 🤖 I noticed you didn't make any changes at the
In order to keep track, I'll create an issue if you decide now is not a good time
|
The raw Catalog Search API exposes the field as PriceToken, but search-graphql/search-resolver expose it as priceToken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
.claude/ holds per-developer Claude Code settings (settings.local.json), which should not be versioned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ack V2) (#219) #### What problem is this solving? **Pricing Fallback V2** ([B2BTEAM-3748](https://vtex-dev.atlassian.net/browse/B2BTEAM-3748), [reference Slack thread](https://vtex.slack.com/archives/C07N5C1GWCB/p1764774437017629)): Intelligent Search now signs the price of each offer and returns it as `priceToken`, so a storefront can forward that signed price on add to cart and the platform can keep closing carts even while the Pricing system is down. Everything upstream of this app is already in production: | Layer | Status | |---|---| | `intsch` price signing (`VTEX.Signer` sidecar) | ✅ production since 2026-07-15, behind a per-account feature flag | | `vtex.search-resolver@1.106.0` / `vtex.search-graphql@0.72.0` | ✅ deployed 2026-07-20 — exposes `priceToken` on the `Offer` type | | `PATCH /api/checkout/pub/orderForm/{id}/items` | ✅ already accepts `priceToken` per order item (confirmed by Guilherme Schirmer in the thread) | | **`vtex.checkout-graphql` (`ItemInput`)** | ❌ **this PR** — the field does not exist, so the storefront cannot send it | **This app is the blocker of the whole chain.** Because `ItemInput` has no `priceToken`, any storefront that forwards the token gets its variable rejected before the request reaches the resolver: ``` 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. ``` That error was reproduced on `b2bstoreqa` with price signing enabled and is currently blocking, in parallel: - `vtex.store-resources` — the product query cannot even be prepared to expose the field usefully ([store-resources PR](vtex-apps/store-resources#199)) - Store Framework — `vtex.add-to-cart-button`, `vtex.minicart`, `vtex.store-components`, per the activity list mapped by Wisney Cardeal in the thread - B2B Suite — [sku-list#17](vtex-apps/sku-list#17), [quickorder#185](vtex-apps/quickorder#185) We are not the owners of this app, so this PR is offered as a starting point for the Checkout Experience team rather than something to merge as is — Thaynan Nunes scheduled a spike for the sprint starting 2026-08-03 precisely to size this change. If the direction is right, it is already validated end to end (see below). **What the change is:** - `graphql/types/Item.graphql`: optional `priceToken: String` on `input ItemInput`, with a docstring explaining the semantics. - `node/typings/global.d.ts`: matching optional `priceToken?: string` on `OrderFormItemInput`. **No resolver change was needed**, and that is deliberate: - `addToCart` builds its REST payload with `items.map(({ options, index, uniqueId, ...rest }) => ...)`, so `priceToken` already flows through `rest` into `checkout.addItem` → `PATCH /orderForm/{id}/items`, which supports the field. - `updateItems` strips only `id`, so it forwards the token as well. - `Checkout.addItem` types items as `Omit<OrderFormItemInput, 'uniqueId' | 'index' | 'options'>`, so the new field is included automatically. **Backwards compatibility.** The field is optional and input-only: when a storefront does not send it, the payload reaching the checkout REST API is byte-for-byte identical to today, and no existing caller has to change. The builder classified the change by itself during `vtex link`: ``` New GraphQL route types or parameters have been added since the previously published version of the app. New features: ItemInput.priceToken was added. ``` i.e. an additive change, publishable as a minor. The token is also deliberately **not** exposed on the `Item` output type — it is signed data that only needs to travel inbound. #### How should this be manually tested? Validated on [b2bstoreqa / pricetoken](https://pricetoken--b2bstoreqa.myvtex.com/notebook-razer/p) with three linked apps: this one, `vtex.store-resources` (product query requesting the field) and `vtex.sku-list` (forwarding it on add to cart). Requires an account with price signing enabled on Intelligent Search — it is still behind a feature flag, and the search team enables it on request. 1. Confirm the search returns the token: `GET /api/intelligent-search/v1/product-search?an={account}` → `items[].sellers[].commertialOffer.PriceToken`. 2. Add an item to the cart from a storefront that forwards the token, or send the mutation directly with `items[0].priceToken`. 3. Before this change: the request fails with the validation error above. After it: the mutation succeeds and the item is added normally. 4. Decoding the forwarded token shows claims bound to the item that was added — `{"price":390,"priceWithoutDiscount":390,"seller":"1","id":"1","accountName":"b2bstoreqa","salesChannel":"1"}`, valid for 30 minutes. 5. Regression: repeat without `priceToken` in the payload and confirm the behaviour is unchanged. Note that the fallback itself is only exercised during a Pricing outage, so nothing about the signed price is observable in the `addToCart` response or in the orderForm. Christian Mutti's guidance in the thread is that the real end-to-end test is to intentionally open the circuit with the Pricing and watch carts still closing — that part is out of reach for us here. #### Checklist/Reminders - [ ] Updated `README.md` — not applicable, no documented behaviour changes for existing callers. - [x] Updated `CHANGELOG.md`. - [x] Linked this PR to a Jira story — [B2BTEAM-3748](https://vtex-dev.atlassian.net/browse/B2BTEAM-3748) (B2B side of the initiative). - [ ] Updated/created tests — happy to add coverage to `node/__tests__/items-mutations.test.ts` if the team wants the pass-through pinned by a test; the current change is schema-only. - [ ] Deleted the workspace after merging this PR — the `pricetoken` workspace is ours and will be unlinked regardless of what happens to this PR. #### Type of changes ✔️ | Type of Change ---|--- _ | Bug fix ✔️ | New feature _ | Breaking change _ | Technical improvements #### Notes Two things worth deciding with the initiative owners rather than in this PR: - **Whether the token should ever be required.** Both Christian Mutti and Guilherme Schirmer stated in the thread that it must stay optional — it is only used during an incident, and an add to cart without a token must keep working. This PR follows that. - **Observability.** There is no way to confirm from the API surface that a token arrived. The plan mentioned in the thread is metrics (a metrics PR on `intsch`, plus "instrument add-to-cart metrics with and without token" on the Store Framework list). If this app should emit anything on its side, that is a natural follow-up and we did not presume it here. [B2BTEAM-3748]: https://vtex-dev.atlassian.net/browse/B2BTEAM-3748?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [B2BTEAM-3748]: https://vtex-dev.atlassian.net/browse/B2BTEAM-3748?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Lucas Vyskubenko <lucas.vysk@vtex.com.br>
|
Your PR has been merged! App is being published. 🚀 After the publishing process has been completed (check #vtex-io-releases) and doing A/B tests with the new version, you can deploy your release by running:
After that your app will be updated on all accounts. For more information on the deployment process check the docs. 📖 |
What does this PR do? *
Pricing Fallback V2 (B2BTEAM-3732): captures the signed price returned by the search and forwards it as
priceTokenin theaddToCartpayload, so the Checkout can close the cart with that price even while the Pricing is unavailable.react/queries/product.gqlandreact/queries/productsByCategory.gqlnow requestcommertialOffer { priceToken }undersellers.AutocompleteBlockcaptures the token of the default seller both ononSelect(single-SKU product) and onselectSku(SKU switch), and sends it oncallAddUnitToCart.CategoryBlockkeeps apriceTokensmap by SKU, filled when the quantity is set, and sends it oncallAddToCart.Note on the field name: the raw Catalog Search API exposes it as
PriceToken(PascalCase), butsearch-graphql/search-resolverexpose it aspriceToken, which is what this code reads.Two deliberate choices:
CategoryBlockthe seller sent to the cart may be thesellerDefaultor the first one in the list (pre-existing fallback), so the token is looked up by the already resolvedsellerIdinstead of assuming the default. The token signsaccountName + skuId + price + seller + salesChannel, so a token taken from a different seller would not validate against the item we send.priceTokenis only added to the payload when the search returns one (conditional spread), keeping it strictly optional, as agreed in the thread: an add to cart must never be blocked by a missing token, since the token only matters during a Pricing incident.Also carries an unrelated one-liner:
.claude/added to.gitignore, since the folder holds per-developer Claude Code settings that should not be versioned.How to test it? *
Requires an account where the price signing feature flag is enabled on the Intelligent Search —
b2bstoreqaalready has it. Confirm with:Depends on the Checkout apps — see Related to / Depends on.
vtex linkthe app and open a page withquickorder.autocomplete.productquery response —sellers[].commertialOffer.priceTokenshould be present.addToCartmutation payload — the item should carrypriceTokenalongsideid,quantityandseller. To tell our request apart from the PDP button in the Network tab: this app sends only{ items }, whilevtex.add-to-cart-buttonalso sendsmarketingDataandallowedOutdatedData.quickorder.category, including a product whose seller comes from the fallback (nosellerDefault), and confirm the token matches the seller that was sent.priceTokenin the payload.End-to-end validation of the fallback itself can only be done by intentionally opening the circuit with the Pricing and checking that orders still close, as pointed out in the thread.
Validation status on
b2bstoreqa(price signing flag enabled):AutocompleteBlock— works. TheProductquery returnssellers[].commertialOffer.priceToken, and the token is a JWT whose payload can be decoded to assert it belongs to the seller being sent (data.seller,data.id,data.pricein cents,exp - iat= 1800s). SKU960137919is a good case: three sellers, two of them with distinct valid tokens, so picking the wrong seller's token would be visible.CategoryBlock— dormant, never exercised end to end.productSearchreturnspriceToken: nullfor every seller, so the token map is always empty and the field is never added to the payload. The token is not missing at the origin: both the Intelligent Search API (full-text and category navigation) and the Catalog Search API returnPriceTokenfor the same SKUs, and the GraphQLproductquery preserves it — onlyproductSearchdrops it. The same SKU also reportsunitMultiplier0 throughproductand 1 throughproductSearch, which suggests the offer is rebuilt by a Checkout simulation in that path, discarding the token. Reported to the Search team; this code starts working with no further change oncesearch-resolverpreserves the field, but it should be validated then.Describe alternatives you've considered, if any. *
search-graphql.Related to / Depends on *
Search side is done:
search-graphql@0.72.0andsearch-resolver@1.106.0were deployed on 2026-07-20, exposingpriceToken. On the Intelligent Search the price signing is still behind a feature flag, enabled only for test accounts.Blocked on vtex-apps/checkout-graphql#219 — do not merge before it ships.
ItemInputonvtex.checkout-graphqlhas nopriceToken, confirmed at runtime: sending it fails the mutation withField "priceToken" is not defined by type ItemInput. Since the search already returns a token on flagged accounts, merging this first would break add to cart. checkout-graphql#219 proposes the field as optional, with the resolver untouched — the rest-spread already forwards it toPATCH /orderForm/{id}/items. The same dependency blocks the equivalent work on Store Framework (store-resources,add-to-cart-button,minicart,store-components) and onvtex-apps/sku-list(B2BTEAM-3748).The mutation lands on the verb that honors the token. Only
PATCH /orderForm/{id}/itemshonorspriceToken—POST /itemsignores it silently, with nothing in the response or in the orderForm to tell you it was dropped. This app is on the honored path:addToCart(vtex.checkout-resources) → theaddToCartresolver incheckout-graphql→checkout.addItem, which issues aPATCHon/api/checkout/pub/orderForm/{id}/itemswithorderItems(node/clients/checkout.ts) — the same shape FastStore confirmed working. That resolver also stripsindexanduniqueIdfrom the items before the call, so nothing here depends on the current cart layout; the checkout engine matches the line by SKU + seller, which is a subset of what the token signs.Out of scope, to be handled in a follow-up:
TextAreaBlockandUploadBlockresolve SKUs through this app's ownskuFromRefIdsresolver (node/resolvers/search/index.ts), which relies onstockkeepingunitidsbyrefids+ Checkout simulation — neither returns the token. Covering them needs a Catalog Search API call innode, a new field onItemsSeller(graphql/types/Refids.graphql), a newoutbound-accesspolicy and propagation throughReviewBlock.Worth confirming with the Checkout team: the token is valid for 30 minutes and cannot be renewed. In
CategoryBlockit is captured when the category is opened, so a user browsing for a long time may send an expired token.