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). Every computation here resolves config via getGamePricingConfig(store, gameId); this spec must not land first.

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,
//   breakdown: [
//     { 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' }
//   ]
// }

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, breakdown } ] }
  • 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.