Appearance
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/preview→syncService.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
configOverrideis a structured body. gamevalidated viaisGameSupported(); card ids resolved through the game plugin's price-join identifier (getSourceCardIdsemantics), price docs loaded via the plugin'spreloadPrices.configOverride, when present, is validated with the samebuildPricingConfigSchema(plugin)factory asPUT /pricing-config, then merged over the store's saved config for that game (same merge semantics asgetGamePricingConfig). 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:
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.Sync PreviewModal — market vs. your price. The price column becomes two: "Market" and "Your Price" (post-pipeline
variantpricefrom 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.Catalog rows — "Your Price" column.
CatalogSetBrowser/CatalogTabtables showyourPricenext 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:
computePriceBreakdownequivalence withcalculatePriceWithCondition(property-style across finishes, rarities, toggled steps); breakdown step ordering andappliedflags;floorSourcecorrectness (byRarity vs global). - Route:
/pricing-preview— saved-config path,configOverridepath (and that nothing persists), unknown card ids, per-game validation (Pokemon rarity keys rejected undergame=mtg), batch cap. - Route:
/sync/previewrows carrymarketPricealongside the computedvariantpriceand flag pre-sale rows. - Client: settings example debounce + rendering of applied/skipped steps; PreviewModal dual-column and pre-sale badge.
