Skip to content

Game-Specific Pricing & Rarity Rules — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Split Store.pricingConfig per game (mtg/pokemon/any registered plugin) so each game's sync page has an independent pricing config whose minimum-price rules key off that game's actual rarities and finishes.

Architecture: A new pricingConfigService resolves per-game configs (plugin defaults merged with stored overrides, with a legacy flat-shape fallback for un-migrated stores). The pricing pipeline's minimum-price step becomes a generic byRarity[rarity][finishKey] lookup, and both raw-price lookups stop collapsing finish keys to a binary normal/foil. All consumers (routes/api.js, syncService, priceUpdateService/processor) resolve config through the service. The client pricing UI is extracted from Home.jsx into a standalone game-aware PricingConfigPanel component (PR #306's nav restructure will later mount this same component at /catalog/:game/settings/pricing — do not build deeper into Home.jsx).

Tech Stack: Node/Express (CommonJS), Mongoose, Zod, Vitest (tests use ESM import), React 18 + retroui/Tailwind.

Spec: docs/superpowers/specs/2026-07-11-game-specific-pricing-rarity-design.md

Global Constraints

  • Server code is CommonJS (require/module.exports); test files use ESM import.
  • Tests co-located as filename.test.js; run with npm test (server), npm run test:client (client). 70% coverage on modified files.
  • Route handlers stay thin; testable logic lives in services (repo has no supertest — route tests are unit tests on the service functions).
  • New/changed endpoints validate with Zod via server/schemas/.
  • Store.pricingConfig becomes mongoose.Schema.Types.Mixed — every write MUST call store.markModified('pricingConfig').
  • Rarity keys are lowercase everywhere (Pokemon keys contain spaces, e.g. 'rare holo'). Finish keys are the plugin's price-bucket ids: MTG normal|foil|etched, Pokemon normal|holofoil|reverseHolofoil|1stEditionNormal|1stEditionHolofoil.
  • Client API calls go through client/src/utils/api.js (axios-style: api.get(path, { params })).
  • Commit after every task; pre-commit hook runs ESLint.

Task 1: Plugin metadata — priceSources + full-finish default minimums

Files:

  • Modify: server/plugins/BaseGamePlugin.js (add priceSources getter near the existing priceSource getter, ~line 63)
  • Modify: server/plugins/mtg/index.js (getDefaultMinimumPrices() ~line 291; add priceSources override)
  • Modify: server/plugins/pokemon/index.js (getDefaultMinimumPrices() ~line 183)
  • Create: server/plugins/pricingDefaults.test.js

Interfaces:

  • Produces: plugin.priceSourcesstring[] (allowed pricing sources, first = default priority). Base returns ['tcgplayer']; MTG overrides with ['tcgplayer', 'cardkingdom'].

  • Produces: plugin.getDefaultMinimumPrices(){ [rarityId]: { [finishId]: number } } covering every key in plugin.finishes.

  • [ ] Step 1: Write the failing test

js
// server/plugins/pricingDefaults.test.js
import { describe, it, expect } from 'vitest';
import { getAllPlugins } from './index.js';

describe('plugin pricing metadata', () => {
    const plugins = Object.values(getAllPlugins());

    it('every plugin declares non-empty priceSources from the known set', () => {
        for (const plugin of plugins) {
            expect(plugin.priceSources.length).toBeGreaterThan(0);
            for (const s of plugin.priceSources) {
                expect(['tcgplayer', 'cardkingdom']).toContain(s);
            }
        }
    });

    it('mtg allows cardkingdom, other games are tcgplayer-only', () => {
        for (const plugin of plugins) {
            if (plugin.gameId === 'mtg') {
                expect(plugin.priceSources).toEqual(['tcgplayer', 'cardkingdom']);
            } else {
                expect(plugin.priceSources).toEqual(['tcgplayer']);
            }
        }
    });

    it('default minimum prices cover every declared finish for every listed rarity', () => {
        for (const plugin of plugins) {
            const defaults = plugin.getDefaultMinimumPrices();
            const finishKeys = Object.keys(plugin.finishes);
            for (const [rarity, mins] of Object.entries(defaults)) {
                for (const finish of finishKeys) {
                    expect(mins[finish], `${plugin.gameId} ${rarity} ${finish}`).toBeTypeOf('number');
                }
            }
        }
    });

    it('default minimum rarity keys are a subset of declared rarities', () => {
        for (const plugin of plugins) {
            const rarityKeys = Object.keys(plugin.rarities);
            for (const rarity of Object.keys(plugin.getDefaultMinimumPrices())) {
                expect(rarityKeys, `${plugin.gameId} ${rarity}`).toContain(rarity);
            }
        }
    });
});
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run server/plugins/pricingDefaults.test.js Expected: FAIL — plugin.priceSources is undefined; MTG defaults lack etched; Pokemon defaults lack 1stEditionNormal/1stEditionHolofoil.

  • [ ] Step 3: Implement

In server/plugins/BaseGamePlugin.js, after the priceSource getter:

js
    /**
     * Pricing sources this game's price documents actually carry.
     * First entry is the default priority. Only MTG has CardKingdom data.
     * @returns {string[]}
     */
    get priceSources() {
        return ['tcgplayer'];
    }

In server/plugins/mtg/index.js, inside the class (near the other getters):

js
    get priceSources() {
        return ['tcgplayer', 'cardkingdom'];
    }

Replace MTG getDefaultMinimumPrices() (etched mirrors foil — preserves today's effective behavior where etched cards received the foil minimum):

js
    getDefaultMinimumPrices() {
        return {
            common: { normal: 0.25, foil: 0.50, etched: 0.50 },
            uncommon: { normal: 0.25, foil: 0.50, etched: 0.50 },
            rare: { normal: 1.29, foil: 1.29, etched: 1.29 },
            mythic: { normal: 0.99, foil: 0.99, etched: 0.99 },
            special: { normal: 0.25, foil: 0.50, etched: 0.50 },
            bonus: { normal: 0.25, foil: 0.50, etched: 0.50 }
        };
    }

Replace Pokemon getDefaultMinimumPrices() — keep every existing rarity row, add the two 1st-edition finish keys to each (1stEditionNormal mirrors normal, 1stEditionHolofoil mirrors holofoil). Example rows (apply the same pattern to all existing rows):

js
    getDefaultMinimumPrices() {
        return {
            common: { normal: 0.25, holofoil: 0.50, reverseHolofoil: 0.50, '1stEditionNormal': 0.25, '1stEditionHolofoil': 0.50 },
            uncommon: { normal: 0.25, holofoil: 0.50, reverseHolofoil: 0.50, '1stEditionNormal': 0.25, '1stEditionHolofoil': 0.50 },
            rare: { normal: 0.50, holofoil: 1.00, reverseHolofoil: 0.75, '1stEditionNormal': 0.50, '1stEditionHolofoil': 1.00 },
            'rare holo': { normal: 1.00, holofoil: 1.00, reverseHolofoil: 1.00, '1stEditionNormal': 1.00, '1stEditionHolofoil': 1.00 },
            'rare holo v': { normal: 1.50, holofoil: 1.50, reverseHolofoil: 1.50, '1stEditionNormal': 1.50, '1stEditionHolofoil': 1.50 },
            'rare holo vmax': { normal: 2.00, holofoil: 2.00, reverseHolofoil: 2.00, '1stEditionNormal': 2.00, '1stEditionHolofoil': 2.00 },
            'rare ultra': { normal: 3.00, holofoil: 3.00, reverseHolofoil: 3.00, '1stEditionNormal': 3.00, '1stEditionHolofoil': 3.00 },
            'rare secret': { normal: 5.00, holofoil: 5.00, reverseHolofoil: 5.00, '1stEditionNormal': 5.00, '1stEditionHolofoil': 5.00 },
            'illustration rare': { normal: 3.00, holofoil: 3.00, reverseHolofoil: 3.00, '1stEditionNormal': 3.00, '1stEditionHolofoil': 3.00 },
            'special illustration rare': { normal: 10.00, holofoil: 10.00, reverseHolofoil: 10.00, '1stEditionNormal': 10.00, '1stEditionHolofoil': 10.00 },
            'hyper rare': { normal: 15.00, holofoil: 15.00, reverseHolofoil: 15.00, '1stEditionNormal': 15.00, '1stEditionHolofoil': 15.00 }
        };
    }

Note the subset test: every rarity key above must exist in plugin.rarities. If any doesn't (e.g. Pokemon 'illustration rare'), add it to the rarities getter with the next sortOrder rather than deleting the default row. Riftbound inherits the base getDefaultMinimumPrices() ({}) — the coverage test iterates Object.entries(defaults) so an empty map passes.

  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run server/plugins/pricingDefaults.test.js Expected: PASS (fix any rarity-key mismatch the subset test surfaces).

  • [ ] Step 5: Run existing plugin tests, then commit

Run: npx vitest run server/plugins/ Expected: PASS (riftbound test untouched).

bash
git add server/plugins/
git commit -m "feat(pricing): plugin priceSources + full-finish default minimums"

Task 2: Store model — pricingConfig becomes per-game Mixed

Files:

  • Modify: server/models/Store.js (replace the pricingConfig subdocument, lines ~101-239; remove minimumPrices from gameConfig.mtg and gameConfig.pokemon, lines ~77-98)
  • Create: server/models/Store.test.js

Interfaces:

  • Produces: Store.pricingConfig is mongoose.Schema.Types.Mixed, default {}. Shape by convention: { [gameId]: <game pricing config> } (legacy docs still hold the old flat shape until migrated — readers handle both via Task 3's service).

  • gameConfig.<game>.enabled and .selectedSets are unchanged (still used by routes/api.js:1627-1668).

  • [ ] Step 1: Write the failing test

js
// server/models/Store.test.js
import { describe, it, expect } from 'vitest';
import Store from './Store.js';

describe('Store schema — per-game pricingConfig', () => {
    const base = { shop: 't.myshopify.com', accessToken: 'x', scope: 'read' };

    it('defaults pricingConfig to an empty object', () => {
        const store = new Store(base);
        expect(store.pricingConfig).toEqual({});
    });

    it('accepts per-game keys without stripping them', () => {
        const store = new Store({
            ...base,
            pricingConfig: {
                mtg: { globalModifier: { enabled: true, value: 1.2, threshold: 49.99 } },
                pokemon: { minimumPrices: { byRarity: { 'rare holo': { holofoil: 2.5 } } } }
            }
        });
        expect(store.pricingConfig.mtg.globalModifier.value).toBe(1.2);
        expect(store.pricingConfig.pokemon.minimumPrices.byRarity['rare holo'].holofoil).toBe(2.5);
    });

    it('no longer defines gameConfig minimumPrices defaults', () => {
        const store = new Store(base);
        expect(store.gameConfig?.mtg?.minimumPrices).toBeUndefined();
        expect(store.gameConfig?.pokemon?.minimumPrices).toBeUndefined();
        // selectedSets survives the cleanup
        expect(store.gameConfig?.mtg?.selectedSets).toEqual([]);
    });
});
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run server/models/Store.test.js Expected: FAIL — typed subdocument strips mtg/pokemon keys and fills old defaults; gameConfig.*.minimumPrices defaults exist.

  • [ ] Step 3: Implement

In server/models/Store.js:

  1. Delete the entire minimumPrices block from gameConfig.mtg (lines ~77-82) and gameConfig.pokemon (lines ~89-98), keeping enabled and selectedSets in both.
  2. Replace the whole pricingConfig: { ... } subdocument (lines ~101-239) with:
js
    // Per-game pricing configuration, keyed by game id (mtg, pokemon, ...).
    // Mixed by design: rarity/finish vocabularies vary per game and are owned
    // by server/plugins/*. Resolve via pricingConfigService.getGamePricingConfig
    // (which also reads the pre-migration flat shape); ALWAYS call
    // markModified('pricingConfig') after writing.
    pricingConfig: {
        type: mongoose.Schema.Types.Mixed,
        default: {}
    },
  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run server/models/Store.test.js Expected: PASS

  • [ ] Step 5: Commit
bash
git add server/models/Store.js server/models/Store.test.js
git commit -m "feat(pricing): Store.pricingConfig becomes per-game Mixed, drop dead gameConfig minimums"

Task 3: pricingConfigService — per-game config resolution

Files:

  • Create: server/services/pricingConfigService.js
  • Create: server/services/pricingConfigService.test.js

Interfaces:

  • Consumes: getPlugin(gameId) from server/plugins (plugin.priceSources, plugin.getDefaultMinimumPrices() from Task 1).

  • Produces (all exported):

    • getDefaultGamePricingConfig(gameId) → full config object for the game.
    • isLegacyFlatConfig(pc)boolean (true when pc is the pre-split flat shape).
    • convertLegacyConfig(flat) → partial config in the new shape (old minimumPrices.rare/mythicbyRarity; numeric globalModifier → object).
    • getGamePricingConfig(store, gameId) → resolved full config (defaults ← stored overrides; flat fallback applies to mtg only).
    • applyGamePricingConfigUpdate(store, gameId, update) → merges update into the store's config for that game, writes store.pricingConfig[gameId], calls markModified, returns the merged config. Does NOT save.
  • [ ] Step 1: Write the failing test

js
// server/services/pricingConfigService.test.js
import { describe, it, expect } from 'vitest';
import {
    getDefaultGamePricingConfig,
    isLegacyFlatConfig,
    convertLegacyConfig,
    getGamePricingConfig,
    applyGamePricingConfigUpdate
} from './pricingConfigService.js';

const LEGACY_FLAT = {
    useRawPlatformPricing: false,
    sourcePriority: ['cardkingdom', 'tcgplayer'],
    globalModifier: { enabled: true, value: 1.25, threshold: 30 },
    rounding: { enabled: false, decimals: [99] },
    minimumPrices: {
        enabled: true,
        global: 0.75,
        rare: { normal: 2.00, foil: 2.50 },
        mythic: { normal: 1.50, foil: 1.75 }
    }
};

function fakeStore(pricingConfig) {
    return { pricingConfig, markModified: () => {} };
}

describe('getDefaultGamePricingConfig', () => {
    it('builds mtg defaults from the plugin', () => {
        const cfg = getDefaultGamePricingConfig('mtg');
        expect(cfg.sourcePriority).toEqual(['tcgplayer', 'cardkingdom']);
        expect(cfg.globalModifier).toEqual({ enabled: true, value: 1.1, threshold: 49.99 });
        expect(cfg.minimumPrices.byRarity.rare).toEqual({ normal: 1.29, foil: 1.29, etched: 1.29 });
        expect(cfg.minimumPrices.global).toBe(0.59);
    });

    it('builds pokemon defaults with pokemon rarities and tcgplayer-only sources', () => {
        const cfg = getDefaultGamePricingConfig('pokemon');
        expect(cfg.sourcePriority).toEqual(['tcgplayer']);
        expect(cfg.minimumPrices.byRarity['rare holo'].holofoil).toBe(1.00);
    });
});

describe('legacy flat shape handling', () => {
    it('detects flat configs and rejects per-game/empty ones', () => {
        expect(isLegacyFlatConfig(LEGACY_FLAT)).toBe(true);
        expect(isLegacyFlatConfig({})).toBe(false);
        expect(isLegacyFlatConfig(undefined)).toBe(false);
        expect(isLegacyFlatConfig({ mtg: { rounding: { enabled: true } } })).toBe(false);
    });

    it('converts rare/mythic minimums to byRarity', () => {
        const converted = convertLegacyConfig(LEGACY_FLAT);
        expect(converted.minimumPrices.byRarity.rare).toEqual({ normal: 2.00, foil: 2.50 });
        expect(converted.minimumPrices.byRarity.mythic).toEqual({ normal: 1.50, foil: 1.75 });
        expect(converted.minimumPrices.rare).toBeUndefined();
        expect(converted.minimumPrices.global).toBe(0.75);
    });

    it('converts pre-nested numeric globalModifier', () => {
        const converted = convertLegacyConfig({ globalModifier: 1.3, modifierThreshold: 25 });
        expect(converted.globalModifier).toEqual({ enabled: true, value: 1.3, threshold: 25 });
    });
});

describe('getGamePricingConfig', () => {
    it('returns pure defaults when the store has nothing', () => {
        const cfg = getGamePricingConfig(fakeStore({}), 'mtg');
        expect(cfg).toEqual(getDefaultGamePricingConfig('mtg'));
    });

    it('treats a legacy flat config as the mtg config', () => {
        const cfg = getGamePricingConfig(fakeStore(LEGACY_FLAT), 'mtg');
        expect(cfg.globalModifier.value).toBe(1.25);
        expect(cfg.minimumPrices.byRarity.rare.normal).toBe(2.00);
        // etched not in legacy shape -> filled from plugin defaults
        expect(cfg.minimumPrices.byRarity.rare.etched).toBe(1.29);
    });

    it('does NOT leak the legacy flat config into pokemon', () => {
        const cfg = getGamePricingConfig(fakeStore(LEGACY_FLAT), 'pokemon');
        expect(cfg).toEqual(getDefaultGamePricingConfig('pokemon'));
    });

    it('per-game overrides win over defaults and stay isolated per game', () => {
        const store = fakeStore({
            mtg: { globalModifier: { value: 2.0 } },
            pokemon: { minimumPrices: { byRarity: { common: { holofoil: 9.99 } } } }
        });
        const mtg = getGamePricingConfig(store, 'mtg');
        const pokemon = getGamePricingConfig(store, 'pokemon');
        expect(mtg.globalModifier.value).toBe(2.0);
        expect(mtg.globalModifier.threshold).toBe(49.99); // default preserved
        expect(pokemon.globalModifier.value).toBe(1.1);   // isolation
        expect(pokemon.minimumPrices.byRarity.common.holofoil).toBe(9.99);
        expect(pokemon.minimumPrices.byRarity.common.normal).toBe(0.25); // default preserved
    });
});

describe('applyGamePricingConfigUpdate', () => {
    it('writes the merged config under the game key and marks modified', () => {
        let marked = null;
        const store = { pricingConfig: {}, markModified: (p) => { marked = p; } };
        const merged = applyGamePricingConfigUpdate(store, 'pokemon', {
            globalModifier: { value: 1.5 }
        });
        expect(store.pricingConfig.pokemon.globalModifier.value).toBe(1.5);
        expect(merged.minimumPrices.byRarity.common.normal).toBe(0.25);
        expect(marked).toBe('pricingConfig');
    });

    it('folds a legacy flat config into mtg before writing another game', () => {
        const store = { pricingConfig: { ...LEGACY_FLAT }, markModified: () => {} };
        applyGamePricingConfigUpdate(store, 'pokemon', { rounding: { enabled: false } });
        expect(store.pricingConfig.mtg.globalModifier.value).toBe(1.25); // preserved
        expect(store.pricingConfig.pokemon.rounding.enabled).toBe(false);
        expect(store.pricingConfig.globalModifier).toBeUndefined(); // flat keys gone
    });
});
  • [ ] Step 2: Run test to verify it fails

Run: npx vitest run server/services/pricingConfigService.test.js Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Implement
js
// server/services/pricingConfigService.js
/**
 * Per-game pricing config resolution.
 *
 * Store.pricingConfig is Mixed, keyed by game id. This module is the single
 * place that (a) builds each game's defaults from its plugin, (b) reads the
 * pre-migration flat shape as the store's MTG config, and (c) merges stored
 * overrides over defaults. Every consumer (routes, syncService,
 * priceUpdateProcessor) resolves config through getGamePricingConfig.
 */
'use strict';

const { getPlugin } = require('../plugins');

const BASE_PIPELINE_DEFAULTS = {
    useRawPlatformPricing: false,
    preSalePlaceholderPrice: 999.99,
    globalModifier: { enabled: true, value: 1.1, threshold: 49.99 },
    conditionVariants: {
        enabled: false,
        conditionMultipliers: { nm: 1.0, lp: 0.95, mp: 0.85, hp: 0.70, damaged: 0.50 }
    },
    rounding: { enabled: true, decimals: [39, 59, 79, 99] },
    minimumPrices: { enabled: true, global: 0.59 }
};

// Plain-object deep merge; arrays and scalars replace, objects recurse.
function deepMerge(base, override) {
    if (override === undefined) return base;
    if (base === undefined) return override;
    if (
        typeof base !== 'object' || base === null || Array.isArray(base) ||
        typeof override !== 'object' || override === null || Array.isArray(override)
    ) {
        return override;
    }
    const out = { ...base };
    for (const key of Object.keys(override)) {
        out[key] = deepMerge(base[key], override[key]);
    }
    return out;
}

function getDefaultGamePricingConfig(gameId) {
    const plugin = getPlugin(gameId);
    return deepMerge(BASE_PIPELINE_DEFAULTS, {
        sourcePriority: [...plugin.priceSources],
        minimumPrices: { byRarity: plugin.getDefaultMinimumPrices() }
    });
}

const FLAT_SHAPE_KEYS = [
    'useRawPlatformPricing', 'sourcePriority', 'preSalePlaceholderPrice',
    'globalModifier', 'conditionVariants', 'rounding', 'minimumPrices',
    'modifierThreshold'
];

function isLegacyFlatConfig(pc) {
    if (!pc || typeof pc !== 'object') return false;
    return FLAT_SHAPE_KEYS.some((key) => key in pc);
}

/**
 * Convert the pre-split flat config into the new per-game shape.
 * Handles both quirky legacy forms the old PUT handler tolerated:
 * numeric globalModifier (+ top-level modifierThreshold) and the
 * rare/mythic minimum structure.
 */
function convertLegacyConfig(flat) {
    const out = {};
    if (flat.useRawPlatformPricing !== undefined) out.useRawPlatformPricing = flat.useRawPlatformPricing;
    if (flat.sourcePriority !== undefined) out.sourcePriority = flat.sourcePriority;
    if (flat.preSalePlaceholderPrice !== undefined) out.preSalePlaceholderPrice = flat.preSalePlaceholderPrice;

    if (typeof flat.globalModifier === 'number') {
        out.globalModifier = {
            enabled: true,
            value: flat.globalModifier,
            threshold: flat.modifierThreshold !== undefined ? flat.modifierThreshold : 49.99
        };
    } else if (flat.globalModifier !== undefined) {
        out.globalModifier = flat.globalModifier;
    }

    if (flat.conditionVariants !== undefined) out.conditionVariants = flat.conditionVariants;
    if (flat.rounding !== undefined) out.rounding = flat.rounding;

    if (flat.minimumPrices !== undefined) {
        const { rare, mythic, ...rest } = flat.minimumPrices;
        const byRarity = {};
        if (rare) byRarity.rare = rare;
        if (mythic) byRarity.mythic = mythic;
        out.minimumPrices = { ...rest };
        if (Object.keys(byRarity).length > 0) out.minimumPrices.byRarity = byRarity;
    }
    return out;
}

function toPlain(pc) {
    return pc && typeof pc.toObject === 'function' ? pc.toObject() : pc;
}

function getGamePricingConfig(store, gameId) {
    const defaults = getDefaultGamePricingConfig(gameId);
    const pc = toPlain(store?.pricingConfig);
    let stored = pc?.[gameId];
    if (!stored && gameId === 'mtg' && isLegacyFlatConfig(pc)) {
        stored = convertLegacyConfig(pc);
    }
    return stored ? deepMerge(defaults, toPlain(stored)) : defaults;
}

function applyGamePricingConfigUpdate(store, gameId, update) {
    const merged = deepMerge(getGamePricingConfig(store, gameId), update);
    const pc = toPlain(store.pricingConfig);
    if (!pc || typeof pc !== 'object' || isLegacyFlatConfig(pc)) {
        // Replace the container; preserve the legacy data by folding it into mtg
        // first so writing pokemon doesn't destroy un-migrated MTG settings.
        const next = {};
        if (isLegacyFlatConfig(pc)) {
            next.mtg = getGamePricingConfig(store, 'mtg');
        }
        store.pricingConfig = next;
    }
    store.pricingConfig[gameId] = merged;
    store.markModified('pricingConfig');
    return merged;
}

module.exports = {
    getDefaultGamePricingConfig,
    isLegacyFlatConfig,
    convertLegacyConfig,
    getGamePricingConfig,
    applyGamePricingConfigUpdate,
    // exported for reuse in tests / other services
    deepMerge
};
  • [ ] Step 4: Run test to verify it passes

Run: npx vitest run server/services/pricingConfigService.test.js Expected: PASS

  • [ ] Step 5: Commit
bash
git add server/services/pricingConfigService.js server/services/pricingConfigService.test.js
git commit -m "feat(pricing): pricingConfigService resolves per-game config with legacy fallback"

Task 4: Pipeline — generic byRarity minimums + finish passthrough

Files:

  • Modify: server/services/priceLookupService.js (DEFAULT_CONFIG ~line 221; applyMinimumPriceRules ~line 342; getRawPriceForFinish ~line 559; getRawPokemonPriceForFinish ~line 898)
  • Modify: server/services/priceLookupService.test.js (update old-shape tests; add new ones)

Interfaces:

  • Consumes: nothing new (config shape from Task 3 by convention).

  • Produces: applyMinimumPriceRules(price, finishKey, rarity, config) — second param is now the price-bucket finish key (normal|foil|etched for MTG, normal|holofoil|reverseHolofoil|1stEditionNormal|1stEditionHolofoil for Pokemon). getRawPriceForFinish(...) / getRawPokemonPriceForFinish(...) return { rawPrice, priceType } where priceType is now that same bucket key (previously collapsed to normal|foil). calculatePriceWithCondition(priceRaw, finishKey, rarity, condition, config) threads it through unchanged.

  • [ ] Step 1: Write the failing tests

Add to server/services/priceLookupService.test.js (imports at top of file already pull from ./priceLookupService.js — extend them with the needed names):

js
describe('applyMinimumPriceRules (byRarity)', () => {
    const config = {
        minimumPrices: {
            enabled: true,
            global: 0.59,
            byRarity: {
                rare: { normal: 1.29, foil: 1.49, etched: 1.99 },
                'rare holo': { holofoil: 2.00, reverseHolofoil: 1.50 }
            }
        }
    };

    it('applies the rarity+finish floor', () => {
        expect(applyMinimumPriceRules(0.10, 'foil', 'rare', config)).toBe(1.49);
        expect(applyMinimumPriceRules(0.10, 'etched', 'rare', config)).toBe(1.99);
    });

    it('gives pokemon finishes independent floors', () => {
        expect(applyMinimumPriceRules(0.10, 'holofoil', 'rare holo', config)).toBe(2.00);
        expect(applyMinimumPriceRules(0.10, 'reverseHolofoil', 'rare holo', config)).toBe(1.50);
    });

    it('falls back to the global minimum for unknown rarity or finish', () => {
        expect(applyMinimumPriceRules(0.10, 'normal', 'common', config)).toBe(0.59);
        expect(applyMinimumPriceRules(0.10, 'holofoil', 'rare', config)).toBe(0.59);
    });

    it('never lowers a price above the floors', () => {
        expect(applyMinimumPriceRules(5.00, 'foil', 'rare', config)).toBe(5.00);
    });

    it('respects the master toggle and enabled flag', () => {
        expect(applyMinimumPriceRules(0.10, 'foil', 'rare', { ...config, useRawPlatformPricing: true })).toBe(0.10);
        expect(applyMinimumPriceRules(0.10, 'foil', 'rare', { minimumPrices: { ...config.minimumPrices, enabled: false } })).toBe(0.10);
    });
});

describe('finish passthrough', () => {
    it('MTG etched keeps its own bucket', () => {
        const priceData = { paper: { tcgplayer: { retail: { etched: 3.33 } } } };
        const { rawPrice, priceType } = getRawPriceForFinish(priceData, 'Etched');
        expect(rawPrice).toBe(3.33);
        expect(priceType).toBe('etched');
    });

    it('MTG display foils still collapse to the foil bucket', () => {
        const priceData = { paper: { tcgplayer: { retail: { foil: 2.22 } } } };
        expect(getRawPriceForFinish(priceData, 'Surge Foil').priceType).toBe('foil');
    });

    it('Pokemon reverse holofoil keeps its own bucket', () => {
        const priceData = { paper: { tcgplayer: { retail: { reverseHolofoil: 1.11 } } } };
        const { rawPrice, priceType } = getRawPokemonPriceForFinish(priceData, 'reverseHolofoil');
        expect(rawPrice).toBe(1.11);
        expect(priceType).toBe('reverseHolofoil');
    });
});
  • [ ] Step 2: Run to verify the new tests fail

Run: npx vitest run server/services/priceLookupService.test.js Expected: new tests FAIL (byRarity ignored; priceType comes back 'foil').

  • [ ] Step 3: Implement

In server/services/priceLookupService.js:

  1. DEFAULT_CONFIG.minimumPrices (~line 250) becomes:
js
    // Step 4: Minimum prices (byRarity keyed on rarity -> finish bucket).
    // Real configs come from pricingConfigService; this is the last-resort
    // fallback when callers pass no config at all.
    minimumPrices: {
        enabled: true,
        global: 0.59,
        byRarity: {
            rare: { normal: 1.29, foil: 1.29, etched: 1.29 },
            mythic: { normal: 0.99, foil: 0.99, etched: 0.99 }
        }
    }
  1. Replace applyMinimumPriceRules (~line 342):
js
/**
 * Apply minimum price rules based on rarity + finish bucket
 * Step 4 of pricing pipeline (final step after rounding)
 * @param {number} price - Price after modifier and rounding
 * @param {string} finishKey - Price-bucket finish key (normal, foil, etched,
 *   holofoil, reverseHolofoil, 1stEditionNormal, 1stEditionHolofoil)
 * @param {string} rarity - Card rarity (lowercase, game-specific vocabulary)
 * @param {object} config - Pricing configuration
 * @returns {number} Price with minimum rules applied
 */
function applyMinimumPriceRules(price, finishKey, rarity, config = DEFAULT_CONFIG) {
    if (config.useRawPlatformPricing) {
        return price;
    }

    const minPrices = config.minimumPrices || DEFAULT_CONFIG.minimumPrices;
    if (minPrices.enabled === false) {
        return price;
    }

    let finalPrice = price;

    const floor = minPrices.byRarity?.[rarity]?.[finishKey];
    if (floor != null) {
        finalPrice = Math.max(finalPrice, floor);
    }

    if (minPrices.global !== undefined) {
        finalPrice = Math.max(finalPrice, minPrices.global);
    }

    return finalPrice;
}
  1. In getRawPriceForFinish (~line 564-565), replace:
js
    const priceKey = FINISH_MAP[finishType] || 'normal';
    const priceType = priceKey === 'normal' ? 'normal' : 'foil';

with:

js
    const priceKey = FINISH_MAP[finishType] || 'normal';
    // priceType IS the price bucket now (normal/foil/etched) so per-finish
    // minimums can distinguish them downstream.
    const priceType = priceKey;
  1. In getRawPokemonPriceForFinish (~line 904-905), replace:
js
    const priceKey = POKEMON_FINISH_MAP[normalizedFinish] || 'normal';
    const priceType = priceKey === 'normal' ? 'normal' : 'foil';

with:

js
    const priceKey = POKEMON_FINISH_MAP[normalizedFinish] || 'normal';
    const priceType = priceKey; // full bucket key for per-finish minimums
  1. Update any existing tests in priceLookupService.test.js that build the old minimumPrices: { rare: {...}, mythic: {...} } shape or assert priceType === 'foil' for etched/holofoil inputs — convert them to the byRarity shape / bucket expectations. Do not delete assertions; port them.
  • [ ] Step 4: Run the full service test file

Run: npx vitest run server/services/priceLookupService.test.js Expected: PASS (all ported + new tests).

  • [ ] Step 5: Run the whole server suite to catch shape fallout early

Run: npm test Expected: failures only in files later tasks own (syncService.test.js, priceUpdateProcessor.test.js, schemas.test.js if they pin the old shape). If a failure is in THIS file's scope, fix it now; note others for their tasks — do not fix them here.

  • [ ] Step 6: Commit
bash
git add server/services/priceLookupService.js server/services/priceLookupService.test.js
git commit -m "feat(pricing): generic byRarity minimums + real finish buckets through the pipeline"

Task 5: Zod schema factory — buildPricingConfigSchema(plugin)

Files:

  • Modify: server/schemas/pricingConfig.js
  • Modify: server/schemas/index.js (export the factory)
  • Modify: server/schemas/schemas.test.js (new describe block)

Interfaces:

  • Consumes: plugin.rarities, plugin.finishes, plugin.priceSources (Task 1).

  • Produces: buildPricingConfigSchema(plugin) → Zod object schema for a partial per-game config update. Rejects rarity keys not in plugin.rarities, finish keys not in plugin.finishes, and sources not in plugin.priceSources. The old flat pricingConfigUpdateSchema stays exported for now — routes/api.js still imports it until Task 6 rewrites the routes; Task 6 deletes it.

  • [ ] Step 1: Write the failing test

Add to server/schemas/schemas.test.js:

js
import { buildPricingConfigSchema } from './pricingConfig.js';
import { getPlugin } from '../plugins/index.js';

describe('buildPricingConfigSchema', () => {
    const mtgSchema = buildPricingConfigSchema(getPlugin('mtg'));
    const pokemonSchema = buildPricingConfigSchema(getPlugin('pokemon'));

    it('accepts a valid mtg partial update', () => {
        const r = mtgSchema.safeParse({
            globalModifier: { value: 1.2 },
            minimumPrices: { byRarity: { rare: { etched: 2.5 } } }
        });
        expect(r.success).toBe(true);
    });

    it('rejects a pokemon rarity key under mtg', () => {
        const r = mtgSchema.safeParse({
            minimumPrices: { byRarity: { 'rare holo': { foil: 1 } } }
        });
        expect(r.success).toBe(false);
    });

    it('rejects a finish key that does not belong to the game', () => {
        const r = pokemonSchema.safeParse({
            minimumPrices: { byRarity: { rare: { etched: 1 } } }
        });
        expect(r.success).toBe(false);
    });

    it('accepts pokemon finishes for pokemon', () => {
        const r = pokemonSchema.safeParse({
            minimumPrices: { byRarity: { 'rare holo': { holofoil: 2, reverseHolofoil: 1.5 } } }
        });
        expect(r.success).toBe(true);
    });

    it('restricts sourcePriority to the plugin priceSources', () => {
        expect(pokemonSchema.safeParse({ sourcePriority: ['cardkingdom'] }).success).toBe(false);
        expect(pokemonSchema.safeParse({ sourcePriority: ['tcgplayer'] }).success).toBe(true);
        expect(mtgSchema.safeParse({ sourcePriority: ['cardkingdom', 'tcgplayer'] }).success).toBe(true);
    });

    it('rejects negative minimums and out-of-range modifiers', () => {
        expect(mtgSchema.safeParse({ minimumPrices: { byRarity: { rare: { foil: -1 } } } }).success).toBe(false);
        expect(mtgSchema.safeParse({ globalModifier: { value: 5.0 } }).success).toBe(false);
    });
});
  • [ ] Step 2: Run to verify failure

Run: npx vitest run server/schemas/schemas.test.js Expected: FAIL — buildPricingConfigSchema not exported.

  • [ ] Step 3: Implement

In server/schemas/pricingConfig.js, keep globalModifierSchema, conditionVariantsSchema, roundingSchema — and leave rarityMinimumSchema, minimumPricesSchema, pricingConfigUpdateSchema, VALID_PRICE_SOURCES untouched for now (routes/api.js still imports pricingConfigUpdateSchema; Task 6 deletes all four with the route rewrite); add:

js
/**
 * Build the per-game pricing-config update schema from a game plugin.
 * Strict on rarity and finish keys: anything outside the plugin's declared
 * vocabulary is a validation error, not silently stored.
 */
function buildPricingConfigSchema(plugin) {
    const finishKeys = Object.keys(plugin.finishes);
    const rarityKeys = Object.keys(plugin.rarities);

    const finishMinimumsSchema = z
        .object(Object.fromEntries(
            finishKeys.map((f) => [f, z.number().min(0).optional()])
        ))
        .strict();

    const byRaritySchema = z
        .object(Object.fromEntries(
            rarityKeys.map((r) => [r, finishMinimumsSchema.optional()])
        ))
        .strict();

    const minimumPricesSchema = z
        .object({
            enabled: z.boolean().optional(),
            global: z.number().min(0).optional(),
            byRarity: byRaritySchema.optional(),
        })
        .optional();

    return z.object({
        useRawPlatformPricing: z.boolean().optional(),
        sourcePriority: z
            .array(z.enum([...plugin.priceSources]))
            .nonempty('sourcePriority must not be empty')
            .optional(),
        preSalePlaceholderPrice: z.number().min(0).optional(),
        globalModifier: globalModifierSchema,
        conditionVariants: conditionVariantsSchema,
        rounding: roundingSchema,
        minimumPrices: minimumPricesSchema,
    });
}

Update module.exports to add buildPricingConfigSchema (keeping all existing exports), and update server/schemas/index.js to also re-export buildPricingConfigSchema. Port any existing schemas.test.js assertions that pinned pricingConfigUpdateSchema behavior (rewrite them against buildPricingConfigSchema(getPlugin('mtg')) — same accept/reject intent); the old schema and its dedicated tests are deleted in Task 6.

  • [ ] Step 4: Run to verify pass

Run: npx vitest run server/schemas/schemas.test.js Expected: PASS

  • [ ] Step 5: Commit
bash
git add server/schemas/
git commit -m "feat(pricing): per-game Zod schema factory keyed on plugin rarities/finishes"

Task 6: Routes — GET/PUT /api/pricing-config?game=

Files:

  • Modify: server/routes/api.js (GET handler ~line 1689; PUT handler ~line 1770; imports ~line 28)
  • Modify: server/services/pricingConfigService.js + test (add buildPricingConfigResponse)

Interfaces:

  • Consumes: getGamePricingConfig / applyGamePricingConfigUpdate (Task 3), buildPricingConfigSchema (Task 5), isGameSupported/getPlugin from server/plugins.

  • Produces (HTTP):

    • GET /api/pricing-config?game=mtg|pokemon (default mtg) → { game, rarities: [{id, name}], finishes: [{id, name}], sources: string[], config }rarities sorted by sortOrder, finishes in declaration order, sources = plugin.priceSources, config fully resolved.
    • PUT /api/pricing-config?game= with a partial-config body → { success: true, message, game, config } (the merged config). 400 on unsupported game or schema violation.
  • Produces (service): buildPricingConfigResponse(store, gameId) → the GET response object (unit-testable without HTTP).

  • [ ] Step 1: Write the failing test (extend server/services/pricingConfigService.test.js)

js
import { buildPricingConfigResponse } from './pricingConfigService.js';

describe('buildPricingConfigResponse', () => {
    it('returns config plus sorted rarity/finish metadata', () => {
        const res = buildPricingConfigResponse(fakeStore({}), 'pokemon');
        expect(res.game).toBe('pokemon');
        expect(res.rarities[0]).toEqual({ id: 'common', name: 'Common' });
        const ids = res.rarities.map((r) => r.id);
        expect(ids.indexOf('common')).toBeLessThan(ids.indexOf('rare holo'));
        expect(res.finishes.map((f) => f.id)).toContain('reverseHolofoil');
        expect(res.sources).toEqual(['tcgplayer']);
        expect(res.config.minimumPrices.byRarity.common.normal).toBe(0.25);
    });
});
  • [ ] Step 2: Run to verify failure

Run: npx vitest run server/services/pricingConfigService.test.js Expected: FAIL — buildPricingConfigResponse not exported.

  • [ ] Step 3: Implement the service function (in pricingConfigService.js)
js
function buildPricingConfigResponse(store, gameId) {
    const plugin = getPlugin(gameId);
    return {
        game: gameId,
        rarities: Object.entries(plugin.rarities)
            .sort(([, a], [, b]) => (a.sortOrder || 0) - (b.sortOrder || 0))
            .map(([id, def]) => ({ id, name: def.name })),
        finishes: Object.entries(plugin.finishes)
            .map(([id, def]) => ({ id, name: def.name })),
        sources: [...plugin.priceSources],
        config: getGamePricingConfig(store, gameId)
    };
}

Add buildPricingConfigResponse to the exports. Run the test file — PASS.

  • [ ] Step 4: Rewrite the route handlers

In server/routes/api.js, replace the entire GET handler body (~lines 1689-1758) and the entire PUT handler (~lines 1770-1905) with:

js
router.get('/pricing-config', async (req, res) => {
    try {
        const game = req.query.game || 'mtg';
        const { isGameSupported } = require('../plugins');
        if (!isGameSupported(game)) {
            return res.status(400).json({ error: `Unsupported game: ${game}` });
        }
        const { buildPricingConfigResponse } = require('../services/pricingConfigService');
        res.json(buildPricingConfigResponse(req.store, game));
    } catch (error) {
        logger.error('Failed to fetch pricing config', { error: error.message });
        res.status(500).json({ error: 'Failed to fetch pricing config' });
    }
});

router.put('/pricing-config', async (req, res) => {
    try {
        const game = req.query.game || 'mtg';
        const { isGameSupported, getPlugin } = require('../plugins');
        if (!isGameSupported(game)) {
            return res.status(400).json({ error: `Unsupported game: ${game}` });
        }

        // Schema depends on the game, so validation is inline rather than via
        // the static validate() middleware.
        const { buildPricingConfigSchema } = require('../schemas');
        const parsed = buildPricingConfigSchema(getPlugin(game)).safeParse(req.body);
        if (!parsed.success) {
            return res.status(400).json({
                error: 'Validation failed',
                details: parsed.error.issues.map((i) => ({
                    path: i.path.join('.'),
                    message: i.message
                }))
            });
        }

        const { applyGamePricingConfigUpdate } = require('../services/pricingConfigService');
        const config = applyGamePricingConfigUpdate(req.store, game, parsed.data);
        await req.store.save();

        logger.info('Updated pricing config for store', { shop: req.store.shop, game });
        res.json({ success: true, message: 'Pricing configuration saved successfully', game, config });
    } catch (error) {
        logger.error('Failed to update pricing config', { error: error.message });
        res.status(500).json({ error: 'Failed to update pricing config' });
    }
});

Also remove the now-unused pricingConfigUpdateSchema import (~line 28) and keep the doc comments above each route, updated to mention ?game=. Then delete the legacy schema pieces Task 5 left behind: rarityMinimumSchema, minimumPricesSchema, pricingConfigUpdateSchema, VALID_PRICE_SOURCES in server/schemas/pricingConfig.js, their re-exports in server/schemas/index.js, and any schemas.test.js tests that target pricingConfigUpdateSchema directly (their intent was ported in Task 5).

  • [ ] Step 5: Lint + full suite

Run: npm run lint && npm test Expected: lint clean; failures only in files owned by Tasks 7-8 (note them, don't fix here).

  • [ ] Step 6: Commit
bash
git add server/routes/api.js server/services/pricingConfigService.js server/services/pricingConfigService.test.js server/schemas/index.js
git commit -m "feat(pricing): game-aware GET/PUT /api/pricing-config with rarity/finish metadata"

Task 7: Consumers — syncService + price-update path resolve per game

Files:

  • Modify: server/services/syncService.js (batch path ~line 215; preview path ~line 853)
  • Modify: server/services/priceUpdateService.js (calculatePriceUpdates signature ~line 400; _findRawPokemonPriceInTimeseries ~line 66)
  • Modify: server/queues/processors/priceUpdateProcessor.js (~line 140, ~line 233)
  • Modify: server/services/syncService.test.js, server/queues/processors/priceUpdateProcessor.test.js (update fixtures/assertions)

Interfaces:

  • Consumes: getGamePricingConfig(store, gameId) (Task 3).

  • Produces: PriceUpdateService.calculatePriceUpdates(variantMap, pricingConfigs, onProgress) — second param is now { mtg: <config>, pokemon: <config> } (a map keyed by game id, not a single config). The MTG loop uses pricingConfigs.mtg, the Pokemon loop pricingConfigs.pokemon.

  • [ ] Step 1: syncService — resolve per game

Both sites already have gameType in scope. At ~line 215 (syncSetDirect), replace:

js
        const store = await Store.findOne({ shop: this.shop });
        const pricingConfig = store?.pricingConfig || undefined;

with:

js
        const store = await Store.findOne({ shop: this.shop });
        const { getGamePricingConfig } = require('./pricingConfigService');
        const pricingConfig = getGamePricingConfig(store, gameType);

At ~line 853 (previewSyncProducts), the same replacement (the variable there is also gameType). pricingConfig is now always defined, so the if (pricingConfig) debug-log guard keeps working; conditionConfig derivation (pricingConfig?.conditionVariants) is unchanged.

  • [ ] Step 2: Write the failing priceUpdateService test

Add to server/queues/processors/priceUpdateProcessor.test.js (or the existing priceUpdateService coverage within it — follow where calculatePriceUpdates is currently exercised). New test:

js
it('prices each game with its own config', async () => {
    // Arrange a variantMap with one MTG variant (rarity 'rare', finish 'Foil')
    // and one Pokemon variant (rarity 'rare holo', finish 'holofoil'), with
    // price caches returning rawPrice 0.10 for both (below all floors).
    // MTG config floor: byRarity.rare.foil = 3.00; global 0.59
    // Pokemon config floor: byRarity['rare holo'].holofoil = 7.00; global 0.59
    const pricingConfigs = {
        mtg: {
            minimumPrices: { enabled: true, global: 0.59, byRarity: { rare: { foil: 3.00 } } }
        },
        pokemon: {
            minimumPrices: { enabled: true, global: 0.59, byRarity: { 'rare holo': { holofoil: 7.00 } } }
        }
    };
    const result = await service.calculatePriceUpdates(variantMap, pricingConfigs);
    const mtgUpdate = result.updates.find((u) => u.sku === MTG_SKU);
    const pokemonUpdate = result.updates.find((u) => u.sku === POKEMON_SKU);
    expect(mtgUpdate.newPrice).toBe(3.00);
    expect(pokemonUpdate.newPrice).toBe(7.00);
});

(Adapt the arrange section to the file's existing mock scaffolding for variantMap, price caches, and product lookups — the test file already builds these for the current single-config tests; mirror that setup.)

  • [ ] Step 3: Run to verify failure

Run: npx vitest run server/queues/processors/priceUpdateProcessor.test.js Expected: new test FAILS (single-config signature).

  • [ ] Step 4: Implement priceUpdateService changes
  1. calculatePriceUpdates(variantMap, pricingConfigs, onProgress = null) — rename the param; at the top of the method add a guard so old single-config callers in tests fail loudly:
js
        if (!pricingConfigs || !pricingConfigs.mtg || !pricingConfigs.pokemon) {
            throw new Error('calculatePriceUpdates requires per-game pricingConfigs { mtg, pokemon }');
        }
  1. In the MTG loop (~lines 585-632), replace every pricingConfig reference with pricingConfigs.mtg (both the findRawPriceInTimeseries(...) call and the calculatePriceWithCondition(...) call).
  2. In the Pokemon loop (~lines 634-680), replace every pricingConfig reference with pricingConfigs.pokemon.
  3. _findRawPokemonPriceInTimeseries (~line 66): change
js
            return { rawPrice: raw, priceType: key === 'normal' ? 'normal' : 'foil' };

to:

js
            return { rawPrice: raw, priceType: key };

(matching Task 4's bucket-key passthrough so Pokemon per-finish minimums apply on the scheduled path too).

  1. In priceUpdateProcessor.js (~line 140), replace:
js
        const pricingConfig = store.pricingConfig;

with:

js
        const { getGamePricingConfig } = require('../../services/pricingConfigService');
        const pricingConfigs = {
            mtg: getGamePricingConfig(store, 'mtg'),
            pokemon: getGamePricingConfig(store, 'pokemon')
        };

and the call site (~line 233) passes pricingConfigs.

  1. Update every existing test that calls calculatePriceUpdates(x, config) with a single config to pass { mtg: config, pokemon: config } — same expectations, new shape. Update any processor test mocking store.pricingConfig similarly (the processor now resolves through getGamePricingConfig, which tolerates {} and legacy shapes, so most fixtures keep working — adjust assertions that pinned the old passthrough).
  • [ ] Step 5: Run affected suites

Run: npx vitest run server/queues/processors/priceUpdateProcessor.test.js server/services/syncService.test.js Expected: PASS. If syncService.test.js mocks Store.findOne to return a store with an old flat pricingConfig, it now flows through the legacy fallback — expectations should hold; update any assertion that pinned the raw flat object being passed to applyPricing (it now receives the resolved config).

  • [ ] Step 6: Commit
bash
git add server/services/syncService.js server/services/priceUpdateService.js server/queues/processors/
git commit -m "feat(pricing): sync + scheduled price updates resolve per-game configs"

Task 8: Migration script

Files:

  • Create: server/scripts/migrations/migratePerGamePricingConfig.js
  • Create: server/scripts/migrations/migratePerGamePricingConfig.test.js
  • Modify: package.json (two scripts)

Interfaces:

  • Consumes: isLegacyFlatConfig, convertLegacyConfig (Task 3).

  • Produces: exported migrateStorePricingConfig(pricingConfig){ changed: boolean, next: object } pure function (unit-tested), plus a CLI entry that applies it to every store. npm run migrate:pricing-config / npm run migrate:pricing-config:dry-run.

  • [ ] Step 1: Write the failing test

js
// server/scripts/migrations/migratePerGamePricingConfig.test.js
import { describe, it, expect } from 'vitest';
import { migrateStorePricingConfig } from './migratePerGamePricingConfig.js';

const LEGACY_FLAT = {
    globalModifier: { enabled: true, value: 1.25, threshold: 30 },
    minimumPrices: { enabled: true, global: 0.75, rare: { normal: 2, foil: 2 }, mythic: { normal: 1.5, foil: 1.5 } }
};

describe('migrateStorePricingConfig', () => {
    it('moves a legacy flat config under mtg in the new shape', () => {
        const { changed, next } = migrateStorePricingConfig(LEGACY_FLAT);
        expect(changed).toBe(true);
        expect(next.mtg.globalModifier.value).toBe(1.25);
        expect(next.mtg.minimumPrices.byRarity.rare.normal).toBe(2);
        expect(next.mtg.minimumPrices.rare).toBeUndefined();
        expect(next.globalModifier).toBeUndefined();
        expect(next.pokemon).toBeUndefined(); // other games fill lazily from defaults
    });

    it('is idempotent: already-migrated and empty configs are untouched', () => {
        expect(migrateStorePricingConfig({ mtg: { rounding: { enabled: true } } }).changed).toBe(false);
        expect(migrateStorePricingConfig({}).changed).toBe(false);
        expect(migrateStorePricingConfig(undefined).changed).toBe(false);
    });
});
  • [ ] Step 2: Run to verify failure

Run: npx vitest run server/scripts/migrations/migratePerGamePricingConfig.test.js Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Implement
js
// server/scripts/migrations/migratePerGamePricingConfig.js
/**
 * Migration: move the pre-split flat Store.pricingConfig under
 * pricingConfig.mtg in the new byRarity shape. Other games are left unset
 * (they resolve lazily from plugin defaults via pricingConfigService).
 *
 * Run with:
 *   node server/scripts/migrations/migratePerGamePricingConfig.js [--dry-run]
 *
 * Idempotent: already-migrated stores are skipped.
 */
require('dotenv').config();
const mongoose = require('mongoose');
const {
    isLegacyFlatConfig,
    convertLegacyConfig
} = require('../../services/pricingConfigService');

function migrateStorePricingConfig(pricingConfig) {
    if (!isLegacyFlatConfig(pricingConfig)) {
        return { changed: false, next: pricingConfig };
    }
    return { changed: true, next: { mtg: convertLegacyConfig(pricingConfig) } };
}

async function run() {
    const dryRun = process.argv.includes('--dry-run');
    const Store = require('../../models/Store');
    const logger = require('../../utils/logger');

    const mongoUri = process.env.MONGODB_URI;
    if (!mongoUri) throw new Error('MONGODB_URI not configured');
    await mongoose.connect(mongoUri);
    logger.info('Connected to MongoDB', { dryRun });

    const stores = await Store.find({}, { shop: 1, pricingConfig: 1 });
    let migrated = 0;
    let skipped = 0;

    for (const store of stores) {
        const pc = store.pricingConfig && store.pricingConfig.toObject
            ? store.pricingConfig.toObject()
            : store.pricingConfig;
        const { changed, next } = migrateStorePricingConfig(pc);
        if (!changed) { skipped++; continue; }

        logger.info(`${dryRun ? '[dry-run] Would migrate' : 'Migrating'} pricingConfig`, { shop: store.shop });
        if (!dryRun) {
            store.pricingConfig = next;
            store.markModified('pricingConfig');
            await store.save();
        }
        migrated++;
    }

    logger.info('Migration complete', { migrated, skipped, dryRun });
    await mongoose.disconnect();
}

module.exports = { migrateStorePricingConfig };

if (require.main === module) {
    run().then(() => process.exit(0)).catch((err) => {
        console.error('Migration failed:', err);
        process.exit(1);
    });
}

In package.json scripts (next to migrate:tokens):

json
    "migrate:pricing-config": "node server/scripts/migrations/migratePerGamePricingConfig.js",
    "migrate:pricing-config:dry-run": "node server/scripts/migrations/migratePerGamePricingConfig.js --dry-run",
  • [ ] Step 4: Run to verify pass

Run: npx vitest run server/scripts/migrations/migratePerGamePricingConfig.test.js Expected: PASS

  • [ ] Step 5: Commit
bash
git add server/scripts/migrations/ package.json
git commit -m "feat(pricing): idempotent migration moving flat pricingConfig under mtg"

Task 9: Client — extract PricingConfigPanel with the dynamic rarity×finish table

Files:

  • Create: client/src/components/PricingConfigPanel.jsx
  • Create: client/src/components/PricingConfigPanel.test.jsx
  • Modify: client/src/pages/Home.jsx (SyncSettingsView ~lines 350-1079; SyncTab render at ~line 342)

Interfaces:

  • Consumes: GET /api/pricing-config?game= response { game, rarities, finishes, config }; PUT /api/pricing-config?game= with a config body (Task 6).

  • Produces:

    • <PricingConfigPanel game="mtg" /> — self-contained pricing settings card (load, edit, save). This is the component PR #306's /catalog/:game/settings/pricing route will mount directly; keep it free of Home.jsx coupling (no props other than game).
    • Exported pure helper buildRarityRows(rarities, finishes, byRarity)[{ rarityId, rarityName, cells: [{ finishId, finishName, value }] }] (unit-tested).
  • [ ] Step 1: Write the failing helper test

jsx
// client/src/components/PricingConfigPanel.test.jsx
import { describe, it, expect } from 'vitest';
import { buildRarityRows } from './PricingConfigPanel';

describe('buildRarityRows', () => {
    const rarities = [
        { id: 'common', name: 'Common' },
        { id: 'rare holo', name: 'Rare Holo' }
    ];
    const finishes = [
        { id: 'normal', name: 'Normal' },
        { id: 'holofoil', name: 'Holofoil' }
    ];

    it('builds one row per rarity with one cell per finish', () => {
        const rows = buildRarityRows(rarities, finishes, {
            common: { normal: 0.25, holofoil: 0.5 },
            'rare holo': { normal: 1.0 }
        });
        expect(rows).toHaveLength(2);
        expect(rows[0]).toEqual({
            rarityId: 'common',
            rarityName: 'Common',
            cells: [
                { finishId: 'normal', finishName: 'Normal', value: 0.25 },
                { finishId: 'holofoil', finishName: 'Holofoil', value: 0.5 }
            ]
        });
    });

    it('missing values come back as empty string for controlled inputs', () => {
        const rows = buildRarityRows(rarities, finishes, {});
        expect(rows[0].cells[0].value).toBe('');
    });
});
  • [ ] Step 2: Run to verify failure

Run: npm run test:client -- --run client/src/components/PricingConfigPanel.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Create the component

client/src/components/PricingConfigPanel.jsx structure:

  1. Move from Home.jsx SyncSettingsView into the new file:
    • the pricingConfig state block and its sibling state (pricingLoading, pricingSaving, pricingSaveSuccess, pricingSaveError) — lines ~368-410
    • loadPricingConfig, savePricingConfig, updatePricingConfig — lines ~418-530
    • the entire "Pricing Configuration" <Card> JSX — lines ~672-1060
    • the imports those pieces need (api, retroui components, icons, useStore)
  2. Component signature and game-aware load/save:
jsx
export function buildRarityRows(rarities, finishes, byRarity) {
    return (rarities || []).map(({ id: rarityId, name: rarityName }) => ({
        rarityId,
        rarityName,
        cells: (finishes || []).map(({ id: finishId, name: finishName }) => ({
            finishId,
            finishName,
            value: byRarity?.[rarityId]?.[finishId] ?? ''
        }))
    }));
}

function PricingConfigPanel({ game = 'mtg' }) {
    const { activeShop } = useStore();
    const [pricingConfig, setPricingConfig] = useState(null);
    const [meta, setMeta] = useState({ rarities: [], finishes: [], sources: [] });
    // ...loading/saving/success/error state moved from SyncSettingsView...

    useEffect(() => {
        if (!activeShop) return;
        loadPricingConfig();
    }, [activeShop, game]);

    const loadPricingConfig = async () => {
        try {
            setPricingLoading(true);
            const { data } = await api.get('/pricing-config', { params: { game } });
            setPricingConfig(data.config);
            setMeta({ rarities: data.rarities, finishes: data.finishes, sources: data.sources });
        } catch (error) {
            console.error('Failed to load pricing config:', error);
        } finally {
            setPricingLoading(false);
        }
    };

    const savePricingConfig = async () => {
        // same success/error handling as before, but:
        await api.put('/pricing-config', pricingConfig, { params: { game } });
    };
    // updatePricingConfig(path, value) moves over unchanged.
    // render: null-guard until pricingConfig loads, then the moved JSX.
}

export default PricingConfigPanel;
  1. Replace the hardcoded minimum-price inputs (the moved JSX block that had the four Rare/Mythic normal/foil inputs plus the global minimum, originally Home.jsx ~lines 941-1005): keep the global-minimum input, then render the dynamic table:
jsx
<div className="overflow-x-auto">
  <table className="w-full text-sm">
    <thead>
      <tr>
        <th className="text-left p-2">Rarity</th>
        {meta.finishes.map((f) => (
          <th key={f.id} className="text-left p-2">{f.name}</th>
        ))}
      </tr>
    </thead>
    <tbody>
      {buildRarityRows(meta.rarities, meta.finishes, pricingConfig.minimumPrices?.byRarity).map((row) => (
        <tr key={row.rarityId} className="border-t">
          <td className="p-2 font-medium">{row.rarityName}</td>
          {row.cells.map((cell) => (
            <td key={cell.finishId} className="p-2">
              <Input
                type="number"
                step="0.01"
                min="0"
                className="w-24"
                value={cell.value}
                onChange={(e) => updatePricingConfig(
                  `minimumPrices.byRarity.${row.rarityId}.${cell.finishId}`,
                  e.target.value === '' ? undefined : parseFloat(e.target.value)
                )}
              />
            </td>
          ))}
        </tr>
      ))}
    </tbody>
  </table>
</div>

Check updatePricingConfig's path-splitting: rarity ids contain spaces ('rare holo') but not dots, so path.split('.') remains safe — verify the moved implementation splits on '.' only and keep it.

  1. Source-priority buttons: the moved JSX has two hardcoded TCGplayer/CardKingdom buttons. Do not hardcode by game — the GET response's sources array (Task 6) is the allowed set. Render one button per entry in meta.sources (highlighting the current sourcePriority[0], clicking an entry moves it to the front of sourcePriority), and hide the whole priority section when meta.sources.length < 2 (Pokemon has only TCGplayer, so there is nothing to prioritize).

  2. In Home.jsx:

    • SyncSettingsView()SyncSettingsView({ game = 'mtg' }); delete the moved pricing state/handlers/JSX; where the pricing card was rendered, mount <PricingConfigPanel game={game} />.
    • Line ~342: {view === 'schedule' && <SyncSettingsView game={game} />} (SyncTab already has game in scope).
    • Remove now-unused imports from Home.jsx.
  • [ ] Step 4: Run client tests + build

Run: npm run test:client -- --run then npm run build Expected: helper tests PASS; existing Home-related client tests PASS; build clean.

  • [ ] Step 5: Manual smoke check (dev)

Run: npm run dev:no-worker, open the MTG sync page → Settings view: pricing card loads with the 6-rarity × 3-finish MTG table pre-filled; switch to /sync/pokemon: table shows Pokemon rarities × 5 finishes; CardKingdom button absent. Save on one game, reload the other — values don't bleed across.

  • [ ] Step 6: Commit
bash
git add client/src/components/PricingConfigPanel.jsx client/src/components/PricingConfigPanel.test.jsx client/src/pages/Home.jsx server/services/pricingConfigService.js server/services/pricingConfigService.test.js
git commit -m "feat(pricing): standalone game-aware PricingConfigPanel with dynamic rarity/finish table"

Task 10: Full verification sweep

Files:

  • Modify: whatever the sweep surfaces (stragglers referencing the old config shape)

  • Modify: CLAUDE.md / server/CLAUDE.md (pricing pipeline notes mention per-game configs)

  • [ ] Step 1: Full server suite with coverage

Run: npm run test:coverage Expected: PASS, 70%+ on modified files. Fix any straggler test still building the old minimumPrices.rare/mythic shape or passing a single config where a per-game map is now required.

  • [ ] Step 2: Client tests + lint + build

Run: npm run test:client -- --run && npm run lint && npm run build Expected: all clean.

  • [ ] Step 3: Grep for dead references

Run: grep -rn "pricingConfigUpdateSchema\|minimumPrices\.rare\|minimumPrices\.mythic" server client --include="*.js" --include="*.jsx" Expected: no hits outside historical docs. Remove any found.

  • [ ] Step 4: Update docs

In root CLAUDE.md (Multi-Stage Pricing bullet) and server/CLAUDE.md (Multi-Stage Pricing section): note that pricing configs are per-game (Store.pricingConfig[gameId], resolved via services/pricingConfigService.js), minimums are byRarity[rarity][finish] keyed on plugin vocabularies, and the migration script npm run migrate:pricing-config moves legacy flat configs.

  • [ ] Step 5: Commit
bash
git add -A
git commit -m "chore(pricing): verification sweep + docs for per-game pricing"

Post-plan reminders (not tasks)

  • Run npm run migrate:pricing-config:dry-run against production after deploy, then the real run. The runtime legacy fallback makes order non-critical, but the migration should still happen promptly.
  • The transparency spec (2026-07-11-pricing-transparency-design.md) builds on getGamePricingConfig — do not start it before this plan lands.
  • PR #306 (nav restructure) will mount PricingConfigPanel at /catalog/:game/settings/pricing; keep the component prop surface to just game.