Skip to content

feat: send priceToken on addToCart in useQuote [B2BTEAM-3733] - #89

Open
wender wants to merge 8 commits into
masterfrom
feature/B2BTEAM-3733_price-token-use-quote
Open

feat: send priceToken on addToCart in useQuote [B2BTEAM-3733]#89
wender wants to merge 8 commits into
masterfrom
feature/B2BTEAM-3733_price-token-use-quote

Conversation

@wender

@wender wender commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

What problem is this solving?

B2BTEAM-3733 — Pricing Fallback V2.

The useQuote mutation applies a quote to the cart by adding the items with { id, seller, quantity } and only then overwrites the price with the negotiated value in a second call. Since the final price is already overwritten manually, a priceToken does not affect the amount charged — but the initial addToCart call can still fail if Pricing is unavailable, blocking the application of already-approved quotes during an incident.

Evaluation (the "avaliar" part of the ticket): unlike the standard flow (Portal/SF/FS/quickorder), useQuote has no live search to reuse a PriceToken from — items come from a Master Data record. Persisting the token with the quote is not viable either: it is valid for 30 minutes while a quote lives for days. The remaining option is to fetch a fresh signed price at the moment the quote is applied, treating the token as optional.

What this PR does:

  • Adds a Catalog client (JanusClient) that queries GET /api/catalog_system/pub/products/search?fq=skuId:…&sc=…. This is the exact endpoint where price signing was delivered in part 1 of the initiative (vtex/catalog-search#445), returning items[].sellers[].commertialOffer.PriceToken. Configured with retries: 1 and timeout: 3000 so it never weighs on the critical path.
  • Forwards the quote's sales channel to the search, because it is part of the signed claims (see the validation below).
  • Adds getPriceTokens, which batches 50 SKUs per request, builds a ${skuId}-${seller} → token map, and swallows any failure (logger.warn + empty map). It reads PriceToken (PascalCase), which is the name used by the Catalog Search REST API — the camelCase priceToken only exists in vtex.search-graphql, which maps the REST field.
  • Moves both cart operations to PATCH /orderForm/{id}/items. Confirmed in the thread that neither POST /items nor POST /items/update honors priceToken, and that PATCH is meant to be the only route used. Adding the items sends no index; overwriting the price sends index. The price payload gains id and seller, which PATCH requires and the old /items/update did not, and it carries priceToken too — otherwise a Pricing outage would still break useQuote on the second call, with the items already in the cart. routes.addToCart and routes.addPriceToItems collapse into one routes.cartItems. RequestHub gains a patch method (there is no patchRaw in @vtex/api, so it resolves to the body and the call site no longer unwraps .data).
  • useQuote attaches priceToken to each orderItem when one is available. With no token, the payload is what it is today.
  • The price override still runs afterwards, so the negotiated price remains the one charged — the token never changes any value.
  • Logs useQuote-priceTokenCoverage (items with/without token, plus the sales channel). Neither the addToCart response nor the orderForm reports whether the token was received or used, and the fallback only activates during a Pricing outage — so this log is the only practical evidence that tokens are getting through, short of simulating an outage.
  • Adds the outbound-access policy for /api/catalog_system/pub/*, catalog typings, and a CHANGELOG entry.

Validation on b2bstoreqa (price signing flag enabled)

Read-only GETs against the account confirm the two assumptions this PR rests on:

Request PriceToken present salesChannel claim exp - iat
intelligent-search/v1/product-search?an=b2bstoreqa&sc=1 yes
catalog_system/pub/products/search?fq=skuId:…&sc=1 yes "1" 1800s
catalog_system/pub/products/search?fq=skuId:… (no sc) yes "1" 1800s
catalog_system/pub/products/search?fq=skuId:…&sc=2 empty result — no token
  1. The endpoint this app uses does return the token. Price signing on the Catalog Search REST API came with part 1 of the initiative, independently of the intsch work.
  2. The token is a JWT signed by session/data-signer, 30 minutes of validity, with claims { price, priceWithoutDiscount, seller, id, accountName, salesChannel }. The signed price is the catalog price, not the negotiated one — which is consistent with this flow: the token only has to get the item into the cart, and the second call then applies the negotiated price as a manual price change.
  3. The sales channel is part of the claims, so it must be forwarded to the search — it is. Without sc the search resolves it to the account default ("1"), the same default addToCart falls back to when the quote carries no sales channel.
  4. If the SKU is not in the quote's sales channel the search simply returns nothing, and the flow proceeds with no token.

End-to-end evidence (workspace b2bstoreqa/pricetoken)

Quote applied through the storefront's "Use quote" button with the app linked. From OpenSearch:

{"level":"info","app":"vtex.b2b-quotes-graphql@4.0.6","account":"b2bstoreqa","workspace":"pricetoken",
 "data":{"message":"useQuote-priceTokenCoverage","itemsWithPriceToken":1,"totalItems":1,"salesChannel":"1"}}

Confirms, from inside the IO service: the catalog search succeeds (no getPriceTokens-catalogSearchError), the ${skuId}-${seller} mapping matches, the sales channel is forwarded, and every item leaves with a token. Note useQuote resolves to null on success by design (the resolver has no return value), so the mutation response carries no signal — this log is the signal.

That run predates the move to PATCH, so it validates the token plumbing but not the current routes. Still to exercise: a multi-SKU, multi-seller quote (stronger test of the composite key and of the index mapping), the 50-SKU batching, and the degradation path.

How to test it?

[Workspace](Link goes here!)

  1. Link the branch to a workspace on an account where the Pricing Fallback feature flag is enabled on the search API (b2bstoreqa was requested for B2B validation; storeframework is the FastStore test store).
  2. Create and approve a quote, then apply it with useQuote.
  3. Confirm the useQuote-priceTokenCoverage log shows itemsWithPriceToken == totalItems.
  4. Confirm the cart ends up with the negotiated prices (the token must not change the final price).
  5. On an account without the flag, confirm useQuote behaves exactly as before (no token in the payload, no error).
  6. Regression from the route change (POST /items and POST /items/updatePATCH /items) — the main thing to exercise now, and none of it needs Pricing to be down:
    • Prices land on the right items. Use a quote with 3+ SKUs at clearly different negotiated prices and check each cart line. The index mapping (itemsAdded.forEach + the isGift skip + the parallel quoteItemIndex counter) assumes the cart returns items in the same order as the quote; if PATCH reorders or consolidates them, the wrong item gets the wrong price. This is a charged-value bug, not a resilience one, and it is the pre-existing fragility most exposed by this change.
    • Quantities are right, with no merged or duplicated lines.
    • A multi-seller quote applies correctly (exercises the ${skuId}-${seller} key).
    • A quote whose salesChannel is not the account default lands on the right channel — ?sc= is undocumented on PATCH, so confirm it is still honored.

Per the thread, the fallback only kicks in during an actual Pricing incident, so an end-to-end validation means intentionally opening the circuit with Pricing and checking orders still close. Sending the field is the testable part here.

Screenshots or example usage:

N/A — backend only.

Describe alternatives you've considered, if any.

  • Persisting the PriceToken alongside the quote in Master Data: discarded, the token expires in 30 minutes while quotes live for days.
  • Reading the token through search-graphql: it is exposed there since search-graphql@0.72.0, but that would add an app dependency and a GraphQL round trip to a backend-only flow. The raw Catalog Search API is where the field originates and needs no app dependency.
  • Doing nothing: valid fallback. There is no functional break today nor expected with the feature launch — this is purely a resilience improvement for applying quotes during Pricing outages.

Points that need a decision before release

  1. Whether POST /items honors the token. Answered: neither POST /items nor POST /items/update does — only PATCH /items, which is meant to be the only route used. Both calls were migrated, so the review focus moves to that change; the remaining risk is behavioral rather than about the token, and item B2BQUOTES-28 #6 under "How to test it?" covers it.
  2. Optional simplification, not in this PR: PATCH /items accepts price on insert, so the two calls could in principle collapse into one and the index mapping would disappear entirely. That is a redesign rather than an adaptation, and it needs confirmation that Checkout accepts a manual price at insert time — worth its own ticket if the index mapping keeps causing trouble.
  3. The new outbound-access policy requires merchants to re-accept app permissions on update. If that is undesirable right now, we can hold the merge until the field leaves the feature flag.
  4. Extra call in the useQuote path: one catalog search (cached, ~10 min API Cache) before addToCart. Bounded by timeout: 3000 / retries: 1 and fully non-blocking on failure.
  5. There is no unit test infrastructure in this repo (Cypress only, via cy-runner), so no automated tests were added.

Not blocking this PR

vtex.checkout-graphql's ItemInput has no priceToken field (vtex-apps/checkout-graphql#219), which blocks the apps that add to cart through the GraphQL mutation — sku-list (B2BTEAM-3748) and quickorder. This app talks to the Checkout REST API directly, so it does not depend on that fix and can move on its own.

Worth flagging to those apps, though: since POST /items does not honor the token, adding priceToken to ItemInput is necessary but may not be sufficient — whatever checkout-graphql calls underneath has to be PATCH too.

Related to / Depends on

How does this PR make you feel? 🔗

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

wender and others added 7 commits August 5, 2026 15:32
…TEAM-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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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.
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.

1 participant