Appearance
Game-Specific Pricing & Rarity Rules β Design β
Problem β
Store.pricingConfig is a single flat object shared by every game a store syncs. The MTG Sync page and Pokemon Sync page both read/write the same config, and the minimum-price-by-rarity step (applyMinimumPriceRules in server/services/priceLookupService.js) hardcodes only rare/mythic β MTG's rarity vocabulary. Pokemon cards (which have ~15-20 rarity tiers: rare holo, rare ultra, rare secret, etc.) silently get MTG's two-rarity minimum-price rule, or none at all for every rarity outside rare/mythic.
The plugin architecture (server/plugins/{mtg,pokemon,riftbound}, extending BaseGamePlugin) already declares each game's rarities, finishes, and getDefaultMinimumPrices() β this was clearly built for per-game pricing but never wired into the actual pipeline or UI.
Goals β
- MTG Sync page and Pokemon Sync page get fully independent pricing configs (source priority, global modifier, condition variants, rounding, minimum prices).
- Minimum-price rules are keyed dynamically off each game's actual rarity list and finish list (not hardcoded MTG terms), including per-finish granularity (Pokemon's holofoil vs reverseHolofoil get separate minimums).
- The system is driven generically by the plugin registry, so a future game (e.g. Riftbound, once it gets a
/syncroute) gets a correct, fully-configurable pricing setup automatically β no new pricing code. - Existing stores' current pricing customizations are preserved as their MTG config; other games start from each plugin's own sensible defaults.
Data Model β
Store.pricingConfig changes from a single typed subdocument to mongoose.Schema.Types.Mixed (default {}), keyed by game id:
js
pricingConfig: {
mtg: {
useRawPlatformPricing: false,
sourcePriority: ['tcgplayer', 'cardkingdom'],
preSalePlaceholderPrice: 999.99,
globalModifier: { enabled: true, value: 1.1, threshold: 49.99 },
conditionVariants: { enabled: false, conditionMultipliers: { nm, lp, mp, hp, damaged } },
rounding: { enabled: true, decimals: [39, 59, 79, 99] },
minimumPrices: {
enabled: true,
global: 0.59,
byRarity: { common: { normal: 0.25, foil: 0.50 }, rare: { normal: 1.29, foil: 1.29 }, ... }
}
},
pokemon: {
/* same shape, byRarity keyed on Pokemon's rarities/finishes */
}
}Mixed is used deliberately (matches the existing pattern in this file β routes already call markModified('pricingConfig') because Mongoose can't autotrack deep mutations on plain nested objects, e.g. today's flatβnested globalModifier migration code). Rarity/finish sets vary per game and can grow (TCGCSV adding rarities), so a fixed schema per rarity isn't viable.
Store.gameConfig.mtg.minimumPrices / gameConfig.pokemon.minimumPrices (dead scaffolding β confirmed unused outside Store.js itself) are removed, fully superseded by pricingConfig[gameId].minimumPrices.byRarity.
Shared Config Resolution β
New helper, getGamePricingConfig(store, gameId) (in server/services/priceLookupService.js or a new server/services/pricingConfigService.js), is the single place that:
- Looks up the game's plugin (
getPlugin(gameId)) for its rarities, finishes, andgetDefaultMinimumPrices(). - Reads
store.pricingConfig?.[gameId]if present. - Back-compat fallback: if no per-game key exists yet AND
store.pricingConfigis in the old flat shape (hasglobalModifier/minimumPricesdirectly at the top level) ANDgameId === 'mtg', treats that flat object as the MTG config. This makes the runtime pipeline safe even for stores that haven't gone through the migration script or hit the API route yet (e.g. the price-update worker, which readsstore.pricingConfigdirectly viasyncService.jswithout ever touching the HTTP routes). - Merges: plugin defaults (rarity minimums) < store overrides, per field.
All consumers of store.pricingConfig switch to this helper:
server/routes/api.jsβ GET/PUT/api/pricing-config(eliminates the current duplication between the GET route's manually-reconstructed defaults andpriceLookupService.js'sDEFAULT_CONFIG).server/services/syncService.jsβ both the batch path (line ~215) and the single-card path (line ~853) resolve the config for the game being synced before callingplugin.applyPricing().- The scheduled price-update path β
priceUpdateProcessor.jscurrently grabsstore.pricingConfigonce andpriceUpdateService.calculatePriceUpdates()applies that single config to both its MTG-variant loop and its Pokemon-variant loop in the same pass. This changes to resolving per-game configs up front (e.g.{ mtg: getGamePricingConfig(store,'mtg'), pokemon: getGamePricingConfig(store,'pokemon') }) and passing the matching config to each game's loop (includingfindRawPriceInTimeseries/_findRawPokemonPriceInTimeseriesandcalculatePriceWithConditioncalls). Without this, scheduled price updates would keep repricing Pokemon products with MTG rules β the exact bug this project exists to fix.
Pricing Pipeline Changes (priceLookupService.js) β
applyMinimumPriceRules(price, finishKey, rarity, config): replace the hardcodedrarity === 'mythic' | 'rare'branches with a generic lookup. Preserves the original semantics exactly β the rarity/finish minimum (if one exists for that rarity+finish) and the global minimum are both applied as independent floors, same as today'sMath.max(finalPrice, minPrices.rare.normal)followed byMath.max(finalPrice, minPrices.global):let floor = config.minimumPrices.byRarity?.[rarity]?.[finishKey]; if (floor != null) finalPrice = Math.max(finalPrice, floor); finalPrice = Math.max(finalPrice, config.minimumPrices.global);- Finish passthrough fix (both games): both
getRawPokemonPriceForFinish()and MTG'sgetRawPriceForFinish()currently collapse the finish into a binarypriceTypeof'normal' | 'foil'before it reaches the minimum-price step (Pokemon: holofoil/reverseHolofoil/1st-edition all β'foil'; MTG: etched β'foil'). This is why per-finish minimums are impossible today. Both will instead pass through the price-bucket key they already compute internally (priceKey): MTG yieldsnormal | foil | etched, Pokemon yieldsnormal | holofoil | reverseHolofoil | 1stEditionNormal | 1stEditionHolofoil. These price-bucket keys exactly match each plugin's declaredfinishes, so they are the finish axis ofbyRarity. (Note: MTG's ~30 display finishes inFINISH_MAPβ Surge Foil, Gilded Foil, etc. β already collapse into these buckets at raw-price lookup and continue to do so; the byRarity table is keyed on price buckets, not display finishes.) - Plugin default coverage: each plugin's
getDefaultMinimumPrices()must cover every finish the plugin declares, so the UI table has no blank cells. MTG adds anetchedvalue per rarity (mirroringfoil, which preserves today's effective behavior β etched cards currently receive the foil minimum because of the collapse described above). Pokemon adds1stEditionNormal(mirroringnormal) and1stEditionHolofoil(mirroringholofoil) β its defaults currently only cover 3 of its 5 declared finishes. DEFAULT_CONFIGremains as the last-resort fallback shape, used only if a plugin has no defaults and the store has no overrides.
API Changes (server/routes/api.js) β
GET /api/pricing-config?game=mtg|pokemonβgamequery param, defaults tomtgfor back-compat. Validates via the existingisGameSupported()plugin-registry check (400 on unsupported game). CallsgetGamePricingConfig(store, game). Response additionally includesraritiesandfinishesmetadata (sorted bysortOrder, same shape as the existing/api/sets/:codeavailableFilters.rarities) so the client can render the per-game minimum-price table without a second round trip.PUT /api/pricing-config?game=mtg|pokemonβ same query param. Validation becomes a schema factory,buildPricingConfigSchema(plugin), sominimumPrices.byRaritykeys/finish-keys are checked against that specific game's actual rarities/finishes (reject unknown keys instead of silently accepting them).sourcePriorityvalid values are game-aware: MTG allows['tcgplayer', 'cardkingdom'](CardKingdom data only exists for MTG); Pokemon (and any other TCGCSV-only game) is restricted to['tcgplayer'].
Client Changes (client/src/pages/Home.jsx) β
- Pricing settings section fetches
GET /api/pricing-config?game={game}and saves viaPUT /api/pricing-config?game={game}, using thegameprop already threaded in fromApp.jsx's/sync(mtg) vs/sync/pokemonroutes. - The current hardcoded 4-input "Rare normal/foil, Mythic normal/foil" block is replaced with a table built from the
rarities/finishesmetadata in the GET response: one row per rarity (sorted bysortOrder), one column per finish, each cell a numeric input bound topricingConfig.minimumPrices.byRarity[rarityId][finishId]. For MTG this is a compact 4-rarity Γ 3-finish table (normal/foil/etched β one column more than today's UI, since etched becomes independently configurable); for Pokemon it's a longer scrollable table (~15 rarities Γ 5 finishes), pre-filled fromgetDefaultMinimumPrices()so merchants aren't starting from blank fields. The pricing card already lives insideSyncTab(which receives thegameprop), so each game's sync page naturally hosts its own pricing UI β no new routes or tabs needed. - Global modifier, condition variants, rounding, and source priority sections keep their current structure, just reading/writing the per-game config.
Migration β
- Runtime safety net:
getGamePricingConfig()'s flat-shape fallback (see above) means nothing breaks between deploy and the migration script running, including paths that never hit the API route. - One-time script:
server/scripts/migrations/migratePerGamePricingConfig.js(matches the existingscripts/migrations/convention, e.g.addPriceUpdateFields.js). For every store with an old flat-shapedpricingConfig, physically moves it topricingConfig.mtg. Idempotent, supports--dry-run(matchesmigrateTokenEncryption.jsconventions).pricingConfig.pokemon(and other games) are left unset β they fill lazily from plugin defaults on first read viagetGamePricingConfig().
Testing β
priceLookupService.test.js: updateapplyMinimumPriceRulestests for the genericbyRaritylookup. Add Pokemon-specific tests asserting holofoil and reverseHolofoil get distinct minimums (previously both collapsed into one'foil'bucket).- New tests for
getGamePricingConfig(): old-flat-shape fallback, plugin- default merge, per-game isolation (changing Pokemon's config doesn't affect MTG's). - Route tests for
GET/PUT /api/pricing-config?game=including rejection of a rarity/finish key that doesn't belong to the requested game, and thesourcePriorityrestriction for non-MTG games. priceUpdateServicetests: a mixed MTG + Pokemon variant map is priced with each game's own config (MTG variants unaffected by Pokemon overrides and vice versa), and MTG etched variants use theetchedminimum rather thanfoil.- Migration script test: dry-run + apply, idempotency on re-run.
Out of Scope β
- Riftbound doesn't have a
/syncroute yet, so it gets no pricing UI in this change β but because the design is plugin-driven rather than hardcoded to MTG/Pokemon, it needs no pricing-code changes once it does. sealedPricingConfig(sealed products) is a separate, already-global config and is not touched by this change.
