Skip to content

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 /sync route) 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:

  1. Looks up the game's plugin (getPlugin(gameId)) for its rarities, finishes, and getDefaultMinimumPrices().
  2. Reads store.pricingConfig?.[gameId] if present.
  3. Back-compat fallback: if no per-game key exists yet AND store.pricingConfig is in the old flat shape (has globalModifier/ minimumPrices directly at the top level) AND gameId === '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 reads store.pricingConfig directly via syncService.js without ever touching the HTTP routes).
  4. 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 and priceLookupService.js's DEFAULT_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 calling plugin.applyPricing().
  • The scheduled price-update path β€” priceUpdateProcessor.js currently grabs store.pricingConfig once and priceUpdateService.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 (including findRawPriceInTimeseries / _findRawPokemonPriceInTimeseries and calculatePriceWithCondition calls). 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 hardcoded rarity === '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's Math.max(finalPrice, minPrices.rare.normal) followed by Math.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's getRawPriceForFinish() currently collapse the finish into a binary priceType of '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 yields normal | foil | etched, Pokemon yields normal | holofoil | reverseHolofoil | 1stEditionNormal | 1stEditionHolofoil. These price-bucket keys exactly match each plugin's declared finishes, so they are the finish axis of byRarity. (Note: MTG's ~30 display finishes in FINISH_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 an etched value per rarity (mirroring foil, which preserves today's effective behavior β€” etched cards currently receive the foil minimum because of the collapse described above). Pokemon adds 1stEditionNormal (mirroring normal) and 1stEditionHolofoil (mirroring holofoil) β€” its defaults currently only cover 3 of its 5 declared finishes.
  • DEFAULT_CONFIG remains 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 β€” game query param, defaults to mtg for back-compat. Validates via the existing isGameSupported() plugin-registry check (400 on unsupported game). Calls getGamePricingConfig(store, game). Response additionally includes rarities and finishes metadata (sorted by sortOrder, same shape as the existing /api/sets/:code availableFilters.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), so minimumPrices.byRarity keys/finish-keys are checked against that specific game's actual rarities/finishes (reject unknown keys instead of silently accepting them).
  • sourcePriority valid 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 via PUT /api/pricing-config?game={game}, using the game prop already threaded in from App.jsx's /sync (mtg) vs /sync/pokemon routes.
  • The current hardcoded 4-input "Rare normal/foil, Mythic normal/foil" block is replaced with a table built from the rarities/finishes metadata in the GET response: one row per rarity (sorted by sortOrder), one column per finish, each cell a numeric input bound to pricingConfig.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 from getDefaultMinimumPrices() so merchants aren't starting from blank fields. The pricing card already lives inside SyncTab (which receives the game prop), 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 existing scripts/migrations/ convention, e.g. addPriceUpdateFields.js). For every store with an old flat-shaped pricingConfig, physically moves it to pricingConfig.mtg. Idempotent, supports --dry-run (matches migrateTokenEncryption.js conventions). pricingConfig.pokemon (and other games) are left unset β€” they fill lazily from plugin defaults on first read via getGamePricingConfig().

Testing ​

  • priceLookupService.test.js: update applyMinimumPriceRules tests for the generic byRarity lookup. 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 the sourcePriority restriction for non-MTG games.
  • priceUpdateService tests: 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 the etched minimum rather than foil.
  • Migration script test: dry-run + apply, idempotency on re-run.

Out of Scope ​

  • Riftbound doesn't have a /sync route 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.