Skip to content

Pricing Transparency — Design

Problem

Merchants can't see what their pricing rules actually do until after a sync lands products in Shopify:

  • Catalog pages show only the raw market price (card.latestPrice); there's no indication of what the merchant's own price would be.
  • The Sync PreviewModal shows only the final computed variantprice. (GET /sync/previewsyncService.previewSyncProducts() does run the pricing pipeline, so the number is truthful — but the market price it was derived from is never shown, so merchants can't see the markup.)
  • The pricing settings card (SyncTab) gives zero feedback while editing — merchants tune modifier/rounding/minimums blind.

Prerequisite

Builds on the per-game pricing split (2026-07-11-game-specific-pricing-rarity-design.md) — shipped. Every computation here resolves config via getGamePricingConfig(store, gameId).

Note on rarity-only games (Pokemon): the per-game split shipped with plugin.minimumPriceGranularity ('rarity+finish' for MTG, 'rarity' for Pokemon — see BaseGamePlugin.js). computePriceBreakdown's floorSource/floor reporting below must handle both: for Pokemon, applyMinimumPriceRules reads byRarity[rarity] as a plain number (ignoring finishKey); for MTG it reads byRarity[rarity][finishKey]. The breakdown step just reports whatever applyMinimumPriceRules actually did — no separate branch needed in computePriceBreakdown itself, since it delegates to that same function.

Note on client file names: this spec was written before the nav restructure (PR #306 and follow-ups) merged into this branch, which deleted Home.jsx/CatalogTab.jsx/CatalogSetBrowser.jsx and replaced them with routed pages. Corrected mapping (verified against the current tree):

  • "Pricing settings card (SyncTab)" → client/src/components/PricingConfigPanel.jsx (mounted by client/src/pages/catalog/CatalogSettingsPricingPage.jsx at /catalog/:game/settings/pricing).
  • "Sync PreviewModal" → unchanged component, client/src/components/PreviewModal.jsx, now triggered from client/src/components/SyncButton.jsx, which is rendered by client/src/pages/catalog/CatalogSetsPage.jsx (/catalog/:game/sets).
  • "CatalogSetBrowser / CatalogTab" → client/src/pages/catalog/CatalogSinglesPage.jsx (/catalog/:game/singles) — this is where card.latestPrice is currently rendered per game (GET /api/sets/:code handlers in server/routes/api.js, ~lines 226-300 for MTG, similar blocks for Pokemon/Riftbound).

Naming collision to avoid: priceCacheService.getLatestPricesForUUIDs() already returns { price, finish, provider, asOf, breakdown } per card, where breakdown is an array of raw prices across finish/provider combinations (extractAllPrices) — a completely different thing from this spec's pipeline step-by-step breakdown. Do not reuse the name breakdown for the new pipeline output in any shared response object; call it pricingBreakdown throughout the API and client to keep the two unambiguous.

Goals

  • Show market price and the store's computed price side by side wherever cards are browsed or previewed.
  • Show a step-by-step breakdown (raw → modifier → condition → rounding → minimum) so merchants understand why the price is what it is.
  • One implementation of the math: the display runs the real pipeline code, never a client-side reimplementation — the preview cannot drift from actual sync behavior.

Backend

Core primitive: computePriceBreakdown

New function in server/services/priceLookupService.js (or the pricing config service, wherever getGamePricingConfig lands):

js
computePriceBreakdown(rawPrice, finishKey, rarity, condition, config)
// → {
//   marketPrice: 4.20,
//   yourPrice: 4.99,
//   pricingBreakdown: [
//     { step: 'modifier',  applied: true,  before: 4.20, after: 4.62 },
//     { step: 'condition', applied: false, before: 4.62, after: 4.62 },
//     { step: 'rounding',  applied: true,  before: 4.62, after: 4.79 },
//     { step: 'minimum',   applied: true,  before: 4.79, after: 4.99,
//       floor: 4.99, floorSource: 'byRarity' | 'global' }
//   ]
// }

(Named pricingBreakdown, not breakdown — see the naming-collision note above.)

Implemented by calling the existing exported pipeline functions (applyPricingMod, calculateConditionPrice, roundPriceDecimals, applyMinimumPriceRules) step by step — the same functions calculatePriceWithCondition composes. A unit test asserts computePriceBreakdown(...).yourPrice === calculatePriceWithCondition(...) across representative inputs, pinning the no-drift guarantee.

Endpoint: POST /api/pricing-preview

One endpoint serves both "what would my saved rules produce" and "what would these unsaved rules produce" (the settings live example):

POST /api/pricing-preview?game=mtg
{
  cards: [ { id: '<uuid|sourceCardId>', finish: 'foil', rarity: 'rare' } ],  // max ~50
  configOverride: { ...partial pricing config... }   // optional
}
→ { results: [ { id, finish, marketPrice, source, timestamp, yourPrice, pricingBreakdown } ] }
  • POST (not GET) because configOverride is a structured body.
  • game validated via isGameSupported(); card ids resolved through the game plugin's price-join identifier (getSourceCardId semantics), price docs loaded via the plugin's preloadPrices.
  • configOverride, when present, is validated with the same buildPricingConfigSchema(plugin) factory as PUT /pricing-config, then merged over the store's saved config for that game (same merge semantics as getGamePricingConfig). Nothing is persisted.
  • Zod schema in server/schemas/ + validate() middleware, per project convention. Batch capped (~50 cards) to bound response size.

Catalog endpoints: attach yourPrice

The catalog handlers in server/routes/api.js that already attach card.latestPrice from a preloaded priceMap additionally attach card.yourPrice (number, saved config, NM condition) computed via the core primitive — for the same finish whose market price is being shown as latestPrice, so the two columns always describe the same thing. No extra queries — the price docs are already in hand. Breakdown is not included here (row-level weight); the modal fetches it on demand via /pricing-preview.

Sync preview: expose the market price

GET /sync/preview (via syncService.previewSyncProducts()) already runs the real pricing pipeline, so variantprice is truthful. The change here is smaller: capture the raw market price alongside the computed one while pricing each product (the raw value is in hand inside plugin.applyPricing / updateVariantPrices before the pipeline runs) and include it on each preview row as marketPrice, so the modal can show "Market → Your price". Pre-sale placeholder behavior (_isPreSale, $999.99 default) surfaces as a flag the UI can badge instead of presenting the placeholder as if it were a computed price.

Client

Three surfaces, in build order:

  1. Pricing settings card (SyncTab) — live worked example. A panel inside the pricing card showing one sample card's full breakdown (market price, then each pipeline step with before/after, greyed-out steps that didn't apply). Recomputes against the unsaved form state via /pricing-preview + configOverride, debounced (~500ms). Sample card: first card of the store's most recent selected set by default, with a search box to pick any card of that game. This is where a merchant learns what each knob does before saving.

  2. Sync PreviewModal — market vs. your price. The price column becomes two: "Market" and "Your Price" (post-pipeline variantprice from the fixed /sync/preview). Pre-sale rows show a "No market data" badge rather than the $999.99 placeholder as a price. Row click (existing ProductDetailModal) adds the breakdown.

  3. Catalog rows — "Your Price" column. CatalogSetBrowser / CatalogTab tables show yourPrice next to the existing market price column. The existing price-history icon/modal remains the market-data drill-down; a small "info" affordance on Your Price opens the breakdown (fetched on demand for that one card).

Deferred (out of scope): overlaying "your price" as a second line on the PriceHistoryModal chart; condition-level breakdowns in the catalog (catalog assumes NM); sealed-product transparency (separate config and pipeline).

Testing

  • Unit: computePriceBreakdown equivalence with calculatePriceWithCondition (property-style across finishes, rarities, toggled steps); breakdown step ordering and applied flags; floorSource correctness (byRarity vs global).
  • Route: /pricing-preview — saved-config path, configOverride path (and that nothing persists), unknown card ids, per-game validation (Pokemon rarity keys rejected under game=mtg), batch cap.
  • Route: /sync/preview rows carry marketPrice alongside the computed variantprice and flag pre-sale rows.
  • Client: settings example debounce + rendering of applied/skipped steps; PreviewModal dual-column and pre-sale badge.