Skip to content

Pricing Transparency 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: Show merchants market price vs. their computed price (with a step-by-step breakdown of why) in three places: the pricing settings page (live example while editing, before saving), the sync preview modal, and catalog browse rows.

Architecture: One new pure primitive, computePriceBreakdown, composes the existing exported pipeline functions (applyPricingMod, calculateConditionPrice inline, roundPriceDecimals, applyMinimumPriceRules) step-by-step instead of just returning a final number — the same functions calculatePriceWithCondition already composes, so the display can never drift from what sync actually computes. A new POST /api/pricing-preview endpoint serves both the settings-page live example (with an optional unsaved configOverride) and on-demand breakdowns elsewhere. The sync-preview and catalog surfaces get a marketPrice/yourPrice pair attached using data that's already being fetched — no new queries for those two.

Tech Stack: Node/Express (CommonJS), Zod, Vitest (server tests use ESM import), React 18 + retroui/Tailwind (client tests use ESM + @testing-library/react).

Spec: docs/superpowers/specs/2026-07-11-pricing-transparency-design.md (see its "Note on client file names" and "Naming collision to avoid" sections — the spec was corrected in-place after the per-game pricing branch merged with main's nav restructure; those corrections are load-bearing for this plan).

Global Constraints

  • Server code is CommonJS (require/module.exports); test files use ESM import. Vitest.
  • The new pipeline-breakdown field is named pricingBreakdown everywhere (API responses, client props) — never breakdown. priceCacheService's existing latestPrice.breakdown is a different thing (raw prices across finish/provider combos, from extractAllPrices) and must not be confused with it.
  • Rarity keys are lowercase; finish keys are each plugin's price-bucket ids (MTG: normal|foil|etched; Pokemon: normal|holofoil|reverseHolofoil|1stEditionNormal|1stEditionHolofoil). Pokemon's minimum-price floor is per-rarity only (plugin.minimumPriceGranularity === 'rarity') — applyMinimumPriceRules already handles this; computePriceBreakdown just delegates to it and reports whatever it did.
  • Every price computation resolves config via getGamePricingConfig(store, gameId) (already shipped) — never re-derive defaults ad hoc.
  • New endpoints validate with Zod via server/schemas/, following the existing per-game schema-factory pattern (buildPricingConfigSchema(plugin) in server/schemas/pricingConfig.js) where the schema depends on which game is requested.
  • Client API calls go through client/src/utils/api.js (api.get(path, {params}), api.post(path, body, {params})).
  • Commit after every task; pre-commit hook runs ESLint — 0 new errors (pre-existing warnings, e.g. security/detect-object-injection, are not your concern).
  • Tests co-located as filename.test.js/.test.jsx. Run npm test (server) / npm run test:client (client) after each task; this codebase's CI machine gets resource-contention flaky failures under load (a prior session hit this repeatedly) — if a test fails only under the full suite but passes cleanly in isolation (npx vitest run <file>), it's a flake, not a regression; note it in your report rather than chasing it.

Task 1: computePriceBreakdown + raw-price timeseries helpers

Files:

  • Modify: server/services/priceLookupService.js (add computePriceBreakdown, findRawPriceForBucket, findRawPokemonPriceInTimeseries; add all three to module.exports)
  • Test: server/services/priceLookupService.test.js

Interfaces:

  • Consumes: existing exported applyPricingMod(priceRaw, config), roundPriceDecimals(price, config), applyMinimumPriceRules(price, finishKey, rarity, config), DEFAULT_CONDITION_MULTIPLIERS, DEFAULT_CONFIG, and the module-local (already-defined, non-exported) roundToCents(value) helper — all in this same file.

  • Produces:

    • computePriceBreakdown(rawPrice, finishKey, rarity, condition = 'nm', config = DEFAULT_CONFIG){ marketPrice: number, yourPrice: number, pricingBreakdown: Array<{step, applied, before, after, floor?, floorSource?}> }. Steps are always ['modifier', 'condition', 'rounding', 'minimum'] in that order. floor/floorSource ('byRarity'|'global') are only present on the 'minimum' step, and only when applied is true.
    • findRawPriceForBucket(priceDocuments, bucketKey, sourcePriority){ rawPrice: number|null, source: string|null, timestamp: Date|null }. Iterates priceDocuments (newest-first) and sourcePriority in order, reading doc.paper[source].retail[bucketKey] directly — no display-name mapping (unlike getRawPriceForFinish/getRawPokemonPriceForFinish, which map MTGJSON/TCGCSV display names to bucket keys via FINISH_MAP/POKEMON_FINISH_MAP). Works for any game since MTG/Pokemon/Riftbound price docs share the { paper: { [source]: { retail: { [bucketKey]: number } } } } shape.
    • findRawPokemonPriceInTimeseries(priceDocuments, finishKey, config = DEFAULT_CONFIG){ rawPrice: number|null, priceType: string }. Mirrors the existing findRawPriceInTimeseries (MTG) but for Pokemon, using getRawPokemonPriceForFinish (already exported) instead of getRawPriceForFinish. finishKey here is a TCGCSV display finish (e.g. 'Holofoil'), matching getRawPokemonPriceForFinish's existing contract — NOT the bucket key (that distinction matters for Task 4, which calls this with variant.finish, a display value).
  • [ ] Step 1: Write the failing tests

Add to server/services/priceLookupService.test.js (the file already imports applyMinimumPriceRules, getRawPriceForFinish, etc. from ./priceLookupService.js — extend that import list with computePriceBreakdown, findRawPriceForBucket, findRawPokemonPriceInTimeseries, calculatePriceWithCondition):

js
describe('computePriceBreakdown', () => {
    const config = {
        globalModifier: { enabled: true, value: 1.1, threshold: 49.99 },
        conditionVariants: {
            enabled: true,
            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,
            byRarity: { rare: { normal: 1.29, foil: 1.29 } }
        }
    };

    it('matches calculatePriceWithCondition exactly (no-drift guarantee)', () => {
        const cases = [
            [4.20, 'normal', 'rare', 'nm'],
            [4.20, 'foil', 'rare', 'lp'],
            [0.10, 'normal', 'common', 'nm'],
            [100.00, 'normal', 'rare', 'nm']
        ];
        for (const [rawPrice, finishKey, rarity, condition] of cases) {
            const { yourPrice } = computePriceBreakdown(rawPrice, finishKey, rarity, condition, config);
            const expected = calculatePriceWithCondition(rawPrice, finishKey, rarity, condition, config);
            expect(yourPrice).toBe(expected);
        }
    });

    it('reports marketPrice as the rounded-to-cents raw price', () => {
        const { marketPrice } = computePriceBreakdown(4.2, 'normal', 'rare', 'nm', config);
        expect(marketPrice).toBe(4.20);
    });

    it('breakdown steps are always modifier, condition, rounding, minimum in order', () => {
        const { pricingBreakdown } = computePriceBreakdown(4.20, 'normal', 'rare', 'nm', config);
        expect(pricingBreakdown.map((s) => s.step)).toEqual(['modifier', 'condition', 'rounding', 'minimum']);
    });

    it('marks a step not applied when it does not change the price', () => {
        // NM condition never applies the condition multiplier (only non-NM does)
        const { pricingBreakdown } = computePriceBreakdown(4.20, 'normal', 'rare', 'nm', config);
        const conditionStep = pricingBreakdown.find((s) => s.step === 'condition');
        expect(conditionStep.applied).toBe(false);
        expect(conditionStep.before).toBe(conditionStep.after);
    });

    it('marks the condition step applied for a non-NM condition when enabled', () => {
        const { pricingBreakdown } = computePriceBreakdown(4.20, 'normal', 'rare', 'lp', config);
        const conditionStep = pricingBreakdown.find((s) => s.step === 'condition');
        expect(conditionStep.applied).toBe(true);
        expect(conditionStep.after).toBeLessThan(conditionStep.before);
    });

    it('reports floorSource byRarity when the rarity/finish floor is the binding one', () => {
        const { pricingBreakdown, yourPrice } = computePriceBreakdown(0.10, 'foil', 'rare', 'nm', config);
        const minStep = pricingBreakdown.find((s) => s.step === 'minimum');
        expect(minStep.applied).toBe(true);
        expect(minStep.floorSource).toBe('byRarity');
        expect(minStep.floor).toBe(1.29);
        expect(yourPrice).toBe(1.29);
    });

    it('reports floorSource global when no rarity/finish floor applies', () => {
        const { pricingBreakdown, yourPrice } = computePriceBreakdown(0.10, 'normal', 'common', 'nm', config);
        const minStep = pricingBreakdown.find((s) => s.step === 'minimum');
        expect(minStep.applied).toBe(true);
        expect(minStep.floorSource).toBe('global');
        expect(minStep.floor).toBe(0.59);
        expect(yourPrice).toBe(0.59);
    });

    it('handles a rarity-only (Pokemon-shaped) byRarity value the same way applyMinimumPriceRules does', () => {
        const pokemonConfig = {
            ...config,
            minimumPrices: { enabled: true, global: 0.59, byRarity: { 'rare holo': 2.00 } }
        };
        const { pricingBreakdown, yourPrice } = computePriceBreakdown(0.10, 'holofoil', 'rare holo', 'nm', pokemonConfig);
        const minStep = pricingBreakdown.find((s) => s.step === 'minimum');
        expect(minStep.floorSource).toBe('byRarity');
        expect(minStep.floor).toBe(2.00);
        expect(yourPrice).toBe(2.00);
    });

    it('every step is unapplied and yourPrice equals marketPrice under useRawPlatformPricing', () => {
        const { marketPrice, yourPrice, pricingBreakdown } = computePriceBreakdown(
            4.20, 'foil', 'rare', 'lp', { ...config, useRawPlatformPricing: true }
        );
        expect(yourPrice).toBe(marketPrice);
        expect(pricingBreakdown.every((s) => s.applied === false)).toBe(true);
    });
});

describe('findRawPriceForBucket', () => {
    it('returns the first non-null, non-zero price for the bucket across sourcePriority', () => {
        const docs = [
            { paper: { tcgplayer: { retail: { foil: 3.50 } } }, timestamp: new Date('2026-01-02') },
            { paper: { tcgplayer: { retail: { foil: 2.00 } } }, timestamp: new Date('2026-01-01') }
        ];
        const result = findRawPriceForBucket(docs, 'foil', ['tcgplayer', 'cardkingdom']);
        expect(result).toEqual({ rawPrice: 3.50, source: 'tcgplayer', timestamp: new Date('2026-01-02') });
    });

    it('falls through to the next source when the first has no value for the bucket', () => {
        const docs = [
            { paper: { cardkingdom: { retail: { foil: 5.00 } } }, timestamp: new Date('2026-01-01') }
        ];
        const result = findRawPriceForBucket(docs, 'foil', ['tcgplayer', 'cardkingdom']);
        expect(result).toEqual({ rawPrice: 5.00, source: 'cardkingdom', timestamp: new Date('2026-01-01') });
    });

    it('returns nulls when nothing matches', () => {
        const result = findRawPriceForBucket([{ paper: {} }], 'foil', ['tcgplayer']);
        expect(result).toEqual({ rawPrice: null, source: null, timestamp: null });
    });

    it('treats a zero or missing price as absent', () => {
        const docs = [{ paper: { tcgplayer: { retail: { foil: 0 } } }, timestamp: new Date() }];
        expect(findRawPriceForBucket(docs, 'foil', ['tcgplayer']).rawPrice).toBeNull();
    });
});

describe('findRawPokemonPriceInTimeseries', () => {
    it('returns the raw price and bucket key for the first valid document', () => {
        const docs = [
            { paper: { tcgplayer: { retail: { holofoil: 4.50 } } } }
        ];
        const result = findRawPokemonPriceInTimeseries(docs, 'Holofoil');
        expect(result).toEqual({ rawPrice: 4.50, priceType: 'holofoil' });
    });

    it('returns null rawPrice with a normal priceType fallback when nothing matches', () => {
        const result = findRawPokemonPriceInTimeseries([{ paper: {} }], 'Holofoil');
        expect(result).toEqual({ rawPrice: null, priceType: 'normal' });
    });
});
  • [ ] Step 2: Run to verify it fails

Run: npx vitest run server/services/priceLookupService.test.js Expected: FAIL — computePriceBreakdown, findRawPriceForBucket, findRawPokemonPriceInTimeseries are not exported/defined; also update the import line at the top of the test file to include calculatePriceWithCondition if it wasn't already imported (check first — it may already be there from a prior task).

  • [ ] Step 3: Implement

In server/services/priceLookupService.js, add near calculatePriceWithCondition (which stays unchanged — this is a parallel implementation, not a replacement, so existing callers are unaffected):

js
/**
 * Same pipeline as calculatePriceWithCondition, but records a step-by-step
 * breakdown instead of just the final number, for display surfaces (pricing
 * settings live example, sync preview, catalog rows). Never used by the
 * actual sync path — calculatePriceWithCondition remains the source of
 * truth there; the equivalence test above pins yourPrice to always match it.
 * @param {number} rawPrice - Raw market price
 * @param {string} finishKey - Price-bucket finish key
 * @param {string} rarity - Card rarity (lowercase)
 * @param {string} condition - Condition code, default 'nm'
 * @param {object} config - Resolved per-game pricing config
 * @returns {{ marketPrice: number, yourPrice: number, pricingBreakdown: Array<object> }}
 */
function computePriceBreakdown(rawPrice, finishKey, rarity, condition = 'nm', config = DEFAULT_CONFIG) {
    const marketPrice = roundToCents(rawPrice);

    if (config.useRawPlatformPricing) {
        return {
            marketPrice,
            yourPrice: marketPrice,
            pricingBreakdown: ['modifier', 'condition', 'rounding', 'minimum'].map((step) => ({
                step, applied: false, before: marketPrice, after: marketPrice
            }))
        };
    }

    const pricingBreakdown = [];

    // Step 1: modifier
    let before = marketPrice;
    let price = applyPricingMod(rawPrice, config);
    pricingBreakdown.push({ step: 'modifier', applied: price !== before, before, after: price });

    // Step 2: condition
    before = price;
    if (condition !== 'nm') {
        const conditionConfig = config.conditionVariants || DEFAULT_CONFIG.conditionVariants;
        const conditionEnabled = !!(conditionConfig && conditionConfig.enabled);
        if (conditionEnabled) {
            const multipliers = conditionConfig.conditionMultipliers || DEFAULT_CONDITION_MULTIPLIERS;
            const multiplier = multipliers[condition] || DEFAULT_CONDITION_MULTIPLIERS[condition] || 1.0;
            price = roundToCents(price * multiplier);
        }
        pricingBreakdown.push({ step: 'condition', applied: conditionEnabled, before, after: price });
    } else {
        pricingBreakdown.push({ step: 'condition', applied: false, before, after: price });
    }

    // Step 3: rounding
    before = price;
    price = roundPriceDecimals(price, config);
    pricingBreakdown.push({ step: 'rounding', applied: price !== before, before, after: price });

    // Step 4: minimum
    before = price;
    price = applyMinimumPriceRules(price, finishKey, rarity, config);
    const applied = price !== before;
    const minStep = { step: 'minimum', applied, before, after: price };
    if (applied) {
        const minPrices = config.minimumPrices || DEFAULT_CONFIG.minimumPrices;
        const rarityEntry = minPrices.byRarity?.[rarity];
        const rarityFloor = typeof rarityEntry === 'number' ? rarityEntry : rarityEntry?.[finishKey];
        if (rarityFloor != null && rarityFloor === price) {
            minStep.floor = rarityFloor;
            minStep.floorSource = 'byRarity';
        } else {
            minStep.floor = minPrices.global;
            minStep.floorSource = 'global';
        }
    }
    pricingBreakdown.push(minStep);

    return { marketPrice, yourPrice: price, pricingBreakdown };
}

/**
 * Find the most recent non-null, non-zero raw price for a price-bucket key
 * directly (no display-name mapping) — for callers that already have the
 * bucket key, e.g. POST /api/pricing-preview, which receives it from the
 * client. Every game's price documents share the same
 * { paper: { [source]: { retail: { [bucketKey]: number } } } } shape.
 * @param {Array<object>} priceDocuments - sorted newest-first
 * @param {string} bucketKey
 * @param {string[]} sourcePriority
 * @returns {{ rawPrice: number|null, source: string|null, timestamp: Date|null }}
 */
function findRawPriceForBucket(priceDocuments, bucketKey, sourcePriority) {
    for (const doc of priceDocuments || []) {
        for (const source of sourcePriority) {
            const retail = doc?.paper?.[source]?.retail;
            const value = retail?.[bucketKey];
            if (typeof value === 'number' && value > 0) {
                return { rawPrice: value, source, timestamp: doc.timestamp || null };
            }
        }
    }
    return { rawPrice: null, source: null, timestamp: null };
}

/**
 * Raw-only Pokemon timeseries lookup, mirroring findRawPriceInTimeseries
 * (MTG). finishKey here is a TCGCSV display finish (e.g. 'Holofoil'),
 * matching getRawPokemonPriceForFinish's existing contract.
 * @param {Array<object>} priceDocuments - sorted newest-first
 * @param {string} finishKey - display finish name
 * @param {object} config
 * @returns {{ rawPrice: number|null, priceType: string }}
 */
function findRawPokemonPriceInTimeseries(priceDocuments, finishKey, config = DEFAULT_CONFIG) {
    for (const priceData of priceDocuments) {
        const { rawPrice, priceType } = getRawPokemonPriceForFinish(priceData, finishKey);
        if (rawPrice !== null && rawPrice > 0) {
            return { rawPrice, priceType };
        }
    }
    return { rawPrice: null, priceType: 'normal' };
}

Add to module.exports (in the existing object, anywhere — e.g. near calculatePriceWithCondition):

js
    computePriceBreakdown,
    findRawPriceForBucket,
    findRawPokemonPriceInTimeseries,
  • [ ] Step 4: Run to verify it passes

Run: npx vitest run server/services/priceLookupService.test.js Expected: PASS (all new tests plus all pre-existing ones).

  • [ ] Step 5: Commit
bash
git add server/services/priceLookupService.js server/services/priceLookupService.test.js
git commit -m "feat(pricing): add computePriceBreakdown primitive and bucket-key raw-price lookups"

Task 2: Zod schema for /pricing-preview

Files:

  • Create: server/schemas/pricingPreview.js
  • Modify: server/schemas/index.js (add ...require('./pricingPreview') to the barrel)
  • Test: server/schemas/schemas.test.js

Interfaces:

  • Consumes: buildPricingConfigSchema(plugin) from server/schemas/pricingConfig.js.

  • Produces: buildPricingPreviewSchema(plugin) → Zod schema for the whole POST /pricing-preview body: { cards: Array<{id, finish, rarity}>, configOverride?: <partial pricing config> }. finish/rarity on each card are restricted to plugin.finishes/plugin.rarities (same enum-of-known-ids pattern as buildPricingConfigSchema). cards is 1–50 items.

  • [ ] Step 1: Write the failing test

Add to server/schemas/schemas.test.js (it already imports buildPricingConfigSchema and getPlugin — extend the import to include buildPricingPreviewSchema):

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

    it('accepts a valid single-card request with no override', () => {
        const r = mtgSchema.safeParse({
            cards: [{ id: 'uuid-1', finish: 'foil', rarity: 'rare' }]
        });
        expect(r.success).toBe(true);
    });

    it('accepts a valid configOverride alongside cards', () => {
        const r = mtgSchema.safeParse({
            cards: [{ id: 'uuid-1', finish: 'normal', rarity: 'common' }],
            configOverride: { globalModifier: { value: 1.25 } }
        });
        expect(r.success).toBe(true);
    });

    it('rejects an empty cards array', () => {
        expect(mtgSchema.safeParse({ cards: [] }).success).toBe(false);
    });

    it('rejects more than 50 cards', () => {
        const cards = Array.from({ length: 51 }, (_, i) => ({ id: `c${i}`, finish: 'normal', rarity: 'common' }));
        expect(mtgSchema.safeParse({ cards }).success).toBe(false);
    });

    it('rejects a finish that does not belong to the game', () => {
        const r = mtgSchema.safeParse({
            cards: [{ id: 'uuid-1', finish: 'holofoil', rarity: 'rare' }]
        });
        expect(r.success).toBe(false);
    });

    it('rejects a rarity that does not belong to the game', () => {
        const r = pokemonSchema.safeParse({
            cards: [{ id: 'c1', finish: 'holofoil', rarity: 'mythic' }]
        });
        expect(r.success).toBe(false);
    });

    it('rejects a configOverride with a cross-game rarity key', () => {
        const r = mtgSchema.safeParse({
            cards: [{ id: 'uuid-1', finish: 'foil', rarity: 'rare' }],
            configOverride: { minimumPrices: { byRarity: { 'rare holo': { foil: 1 } } } }
        });
        expect(r.success).toBe(false);
    });

    it('accepts a pokemon rarity-only configOverride', () => {
        const r = pokemonSchema.safeParse({
            cards: [{ id: 'c1', finish: 'holofoil', rarity: 'rare holo' }],
            configOverride: { minimumPrices: { byRarity: { 'rare holo': 2.5 } } }
        });
        expect(r.success).toBe(true);
    });
});
  • [ ] Step 2: Run to verify it fails

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

  • [ ] Step 3: Implement
js
// server/schemas/pricingPreview.js
/**
 * Zod schema for POST /api/pricing-preview — validates the batch of cards
 * to price and an optional unsaved configOverride, both restricted to the
 * requested game's actual rarity/finish vocabulary.
 */
'use strict';

const { z } = require('zod');
const { buildPricingConfigSchema } = require('./pricingConfig');

const MAX_PREVIEW_CARDS = 50;

function buildPricingPreviewSchema(plugin) {
    const rarityKeys = Object.keys(plugin.rarities);
    const finishKeys = Object.keys(plugin.finishes);

    const cardSchema = z.object({
        id: z.string().min(1),
        finish: z.enum([...finishKeys]),
        rarity: z.enum([...rarityKeys]),
    });

    return z.object({
        cards: z.array(cardSchema).min(1, 'cards must not be empty').max(MAX_PREVIEW_CARDS, `cards must not exceed ${MAX_PREVIEW_CARDS}`),
        configOverride: buildPricingConfigSchema(plugin).optional(),
    });
}

module.exports = {
    buildPricingPreviewSchema,
    MAX_PREVIEW_CARDS,
};

In server/schemas/index.js, add alongside the existing pricing entries:

js
    ...require('./pricingPreview'),
  • [ ] Step 4: Run to verify it passes

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

  • [ ] Step 5: Commit
bash
git add server/schemas/pricingPreview.js server/schemas/index.js server/schemas/schemas.test.js
git commit -m "feat(pricing): add per-game Zod schema for the pricing-preview request body"

Task 3: POST /api/pricing-preview route

Files:

  • Modify: server/routes/api.js (add route near the existing GET/PUT /pricing-config routes, ~line 1690-1900)

Interfaces:

  • Consumes: isGameSupported/getPlugin (server/plugins), getGamePricingConfig/deepMerge (server/services/pricingConfigService.jsdeepMerge is already exported per that service's module.exports), buildPricingPreviewSchema (Task 2), findRawPriceForBucket/computePriceBreakdown (Task 1), plugin.preloadPrices(cardIds) (existing SYNC INTERFACE method, returns Map<cardId, priceDocuments[]>).

  • Produces (HTTP): POST /api/pricing-preview?game=mtg|pokemon with body { cards: [{id, finish, rarity}], configOverride? }{ results: [{ id, finish, marketPrice, source, timestamp, yourPrice, pricingBreakdown }] }. A card with no price data returns marketPrice: null, yourPrice: null, pricingBreakdown: [] for that entry (not a request-level error). 400 on unsupported game or schema validation failure (with details, matching the PUT /pricing-config error-shape convention). Nothing is persisted regardless of configOverride.

  • [ ] Step 1: Manual verification plan (no dedicated route-level test file — this repo has no supertest; route correctness is proven by (a) the Task 1/2 unit tests covering the primitives this route composes, and (b) a smoke check here)

This task has no separate TDD test file (matching the established pattern for GET/PUT /pricing-config, which also has no dedicated route test — see server/services/pricingConfigService.test.js's buildPricingConfigResponse tests instead, which test the service-layer logic the route calls). Write the route, then do a manual smoke check via curl against the local dev server (Step 4) to confirm the wiring.

  • [ ] Step 2: Implement

Add to server/routes/api.js, near the existing pricing-config routes:

js
/**
 * POST /api/pricing-preview
 * Compute market price + your computed price (with a step-by-step
 * pipeline breakdown) for a batch of cards, using either the store's saved
 * pricing config or an unsaved configOverride (never persisted). Powers
 * the pricing settings live example and on-demand breakdown modals.
 */
router.post('/pricing-preview', 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}` });
        }
        const plugin = getPlugin(game);

        const { buildPricingPreviewSchema } = require('../schemas');
        const parsed = buildPricingPreviewSchema(plugin).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 { cards, configOverride } = parsed.data;

        const { getGamePricingConfig, deepMerge } = require('../services/pricingConfigService');
        const savedConfig = getGamePricingConfig(req.store, game);
        const config = configOverride ? deepMerge(savedConfig, configOverride) : savedConfig;

        const { findRawPriceForBucket, computePriceBreakdown } = require('../services/priceLookupService');

        const cardIds = [...new Set(cards.map((c) => c.id))];
        const priceCache = await plugin.preloadPrices(cardIds);
        const sourcePriority = config.sourcePriority?.length ? config.sourcePriority : [...plugin.priceSources];

        const results = cards.map((card) => {
            const priceDocuments = priceCache.get(card.id) || [];
            const { rawPrice, source, timestamp } = findRawPriceForBucket(priceDocuments, card.finish, sourcePriority);
            if (rawPrice == null) {
                return {
                    id: card.id, finish: card.finish,
                    marketPrice: null, source: null, timestamp: null,
                    yourPrice: null, pricingBreakdown: []
                };
            }
            const { marketPrice, yourPrice, pricingBreakdown } = computePriceBreakdown(
                rawPrice, card.finish, card.rarity, 'nm', config
            );
            return { id: card.id, finish: card.finish, marketPrice, source, timestamp, yourPrice, pricingBreakdown };
        });

        res.json({ results });
    } catch (error) {
        logger.error('Failed to compute pricing preview', { error: error.message });
        res.status(500).json({ error: 'Failed to compute pricing preview' });
    }
});
  • [ ] Step 3: Run the full server suite to confirm nothing broke

Run: npm test Expected: all pass (this is an additive route; nothing existing should change behavior).

  • [ ] Step 4: Manual smoke check

Start the dev server (npm run dev:no-worker or equivalent) and, against a store with at least one synced MTG card, run:

bash
curl -s -X POST "http://localhost:3000/api/pricing-preview?game=mtg" \
  -H "Content-Type: application/json" \
  -H "Cookie: <valid session cookie from a logged-in browser session>" \
  -d '{"cards":[{"id":"<a real card uuid>","finish":"normal","rarity":"rare"}]}' | jq

Expected: { "results": [{ "id": "...", "finish": "normal", "marketPrice": <number>, "source": "tcgplayer"|"cardkingdom", "timestamp": "...", "yourPrice": <number>, "pricingBreakdown": [...] }] }. If you don't have an easy way to get a valid session cookie, it's acceptable to verify this step by reading the code path carefully instead and noting in your report that live smoke-testing wasn't performed and why — do not skip silently.

  • [ ] Step 5: Commit
bash
git add server/routes/api.js
git commit -m "feat(pricing): add POST /api/pricing-preview endpoint"

Task 4: Thread marketPrice through the sync/preview pricing path

Files:

  • Modify: server/services/priceLookupService.js (getPricesForCard ~line 658, getPricesForPokemonCard ~line 961, updateVariantPrices ~line 764, updatePokemonVariantPrices ~line 1071 — locate the current line numbers yourself via grep -n "^async function getPricesForCard\|^async function getPricesForPokemonCard\|^async function updateVariantPrices\|^async function updatePokemonVariantPrices" server/services/priceLookupService.js, they may have shifted slightly after Task 1)
  • Modify: server/plugins/pokemon/index.js (applyPricing, ~line 390)
  • Test: server/services/priceLookupService.test.js

Interfaces:

  • Consumes: findRawPriceInTimeseries (existing, MTG), findRawPokemonPriceInTimeseries (Task 1, Pokemon).

  • Produces: getPricesForCard(...)'s returned object gains a _rawPrices: { [finishType]: number|null } key (sibling to the existing _isPreSale metadata key — same underscore-prefixed convention, ignored by existing callers that don't know about it). Same for getPricesForPokemonCard. updateVariantPrices/updatePokemonVariantPrices set variant.marketPrice on each variant they price, alongside the existing variant.price. PokemonPlugin.applyPricing copies tempVariants[0].marketPrice to product.variantMarketPrice, alongside its existing product.variantprice copy.

  • [ ] Step 1: Write the failing tests

Add to server/services/priceLookupService.test.js:

js
describe('marketPrice threading', () => {
    it('getPricesForCard exposes raw prices per finish alongside the computed prices', async () => {
        const priceCache = new Map([
            ['card-uuid-1', [{ paper: { tcgplayer: { retail: { normal: 5.00, foil: 10.00 } } }, timestamp: new Date() }]]
        ]);
        const prices = await getPricesForCard('card-uuid-1', 'rare', ['Normal', 'Foil'], DEFAULT_CONFIG, { priceCache });
        expect(prices._rawPrices.Normal).toBe(5.00);
        expect(prices._rawPrices.Foil).toBe(10.00);
    });

    it('updateVariantPrices sets variant.marketPrice alongside variant.price', async () => {
        const priceCache = new Map([
            ['card-uuid-2', [{ paper: { tcgplayer: { retail: { normal: 5.00 } } }, timestamp: new Date() }]]
        ]);
        const variants = [{ finish: 'Normal', sku: 'sku-1', price: 0 }];
        await updateVariantPrices(variants, 'card-uuid-2', 'rare', DEFAULT_CONFIG, { priceCache });
        expect(variants[0].marketPrice).toBe(5.00);
        expect(variants[0].price).toBeGreaterThan(0);
    });

    it('getPricesForPokemonCard exposes raw prices per finish alongside the computed prices', async () => {
        const priceCache = new Map([
            ['pokemon-card-1', [{ paper: { tcgplayer: { retail: { holofoil: 8.00 } } }, timestamp: new Date() }]]
        ]);
        const prices = await getPricesForPokemonCard('pokemon-card-1', 'rare holo', ['Holofoil'], DEFAULT_CONFIG, { priceCache });
        expect(prices._rawPrices.Holofoil).toBe(8.00);
    });

    it('updatePokemonVariantPrices sets variant.marketPrice alongside variant.price', async () => {
        const priceCache = new Map([
            ['pokemon-card-2', [{ paper: { tcgplayer: { retail: { holofoil: 8.00 } } }, timestamp: new Date() }]]
        ]);
        const variants = [{ finish: 'Holofoil', sku: 'sku-2', price: 0 }];
        await updatePokemonVariantPrices(variants, 'pokemon-card-2', 'rare holo', DEFAULT_CONFIG, { priceCache });
        expect(variants[0].marketPrice).toBe(8.00);
        expect(variants[0].price).toBeGreaterThan(0);
    });
});
  • [ ] Step 2: Run to verify it fails

Run: npx vitest run server/services/priceLookupService.test.js Expected: FAIL — _rawPrices/marketPrice are undefined.

  • [ ] Step 3: Implement

In getPricesForCard, inside the finishTypes.forEach loop (the one that currently only calls findValidPriceInTimeseries), also capture the raw price:

js
        const prices = {};
        const rawPrices = {};
        const cardRarity = rarity ? rarity.toLowerCase() : '';
        let hasAnyValidPrice = false;

        finishTypes.forEach(finishType => {
            const price = findValidPriceInTimeseries(priceDocuments, finishType, cardRarity, config);
            const { rawPrice } = findRawPriceInTimeseries(priceDocuments, finishType, config);
            rawPrices[finishType] = rawPrice;

            if (price) {
                hasAnyValidPrice = true;
                prices[finishType] = price;
            } else {
                prices[finishType] = getPreSalePlaceholderPrice(config);
                logger.debug(`No valid price found in timeseries for ${cardUUID} ${finishType}, using pre-sale placeholder: ${prices[finishType]}`);
            }
        });

        prices._rawPrices = rawPrices;

        if (!hasAnyValidPrice) {
            prices._isPreSale = true;
            logger.warn(`No valid prices found for any finish of card ${cardUUID} - marking as pre-sale with placeholder pricing`);
        }

(This adds two lines inside the existing loop — const { rawPrice } = ... and rawPrices[finishType] = rawPrice; — plus prices._rawPrices = rawPrices; before the existing if (!hasAnyValidPrice) block. The pre-sale-return branch above this loop, and the catch-block's error-return branch, both return early without price documents — leave those as-is, no _rawPrices needed since there's no raw data to report.)

In updateVariantPrices, in the variants.forEach loop:

js
    variants.forEach(variant => {
        const finishType = variant.finish || 'Normal';
        variant.price = prices[finishType] || globalMin;
        variant.marketPrice = (prices._rawPrices && prices._rawPrices[finishType]) ?? null;

        logger.debug(`Updated variant price`, {
            cardUUID,
            finish: finishType,
            price: variant.price,
            isPreSale
        });
    });

Apply the identical pattern to getPricesForPokemonCard (same finishTypes.forEach shape, using findRawPokemonPriceInTimeseries instead of findRawPriceInTimeseries) and updatePokemonVariantPrices (same variants.forEach addition). Read both functions first — they mirror getPricesForCard/updateVariantPrices closely but are not byte-identical (e.g. Pokemon's forEach may reference getPricesForPokemonCard's specific placeholder/pre-sale wording) — match the existing style of whichever function you're editing.

In server/plugins/pokemon/index.js's applyPricing (~line 390), add the market-price copy-back alongside the existing price copy-back:

js
    async applyPricing(product, pricingConfig, priceCache) {
        const { updatePokemonVariantPrices } = require('../../services/priceLookupService');
        if (!product.sourceCardId) return { isPreSale: false };

        const rarity = product.metafields?.rarity || 'common';
        const tempVariants = [{
            finish: product.metafields?.finish || 'normal',
            sku: product.variantsku || product.sku,
            price: product.variantprice || 0
        }];
        const { isPreSale } = await updatePokemonVariantPrices(
            tempVariants, product.sourceCardId, rarity, pricingConfig, { priceCache }
        );
        product.variantprice = tempVariants[0].price;
        product.variantMarketPrice = tempVariants[0].marketPrice;
        return { isPreSale };
    }

(MTG needs no plugin-level change — product.variants[] already IS the real variants array there, so variant.marketPrice set inside updateVariantPrices persists directly; MTGPlugin.applyPricing just passes product.variants through by reference, unchanged.)

  • [ ] Step 4: Run to verify it passes

Run: npx vitest run server/services/priceLookupService.test.js Expected: PASS. Then run npx vitest run server/plugins/ to confirm the Pokemon plugin change didn't break its existing tests.

  • [ ] Step 5: Update /sync/preview's route doc comment to mention marketPrice is now present

In server/routes/api.js, find the GET /sync/preview route's doc comment (router.get('/sync/preview', ...), ~line 1846) and add one line noting each product/variant now carries marketPrice alongside price/variantprice (no code change needed in the route itself — previewSyncProducts()'s return value already flows straight into res.json({ success: true, preview }), so this new field appears automatically).

  • [ ] Step 6: Run the full server suite

Run: npm test Expected: all pass.

  • [ ] Step 7: Commit
bash
git add server/services/priceLookupService.js server/services/priceLookupService.test.js server/plugins/pokemon/index.js server/routes/api.js
git commit -m "feat(pricing): thread raw market price through the sync-preview pricing path"

Task 5: Catalog endpoints attach card.yourPrice

Files:

  • Modify: server/routes/api.js (all four card.latestPrice = priceMap.get(...) sites — locate via grep -n "card.latestPrice = priceMap.get" server/routes/api.js, currently ~lines 293, 428, 535, 1030, covering MTG/Pokemon/Riftbound set-detail handlers and one single-card detail handler)

Interfaces:

  • Consumes: getGamePricingConfig(store, gameId) (existing), computePriceBreakdown (Task 1). Each handler already has card.rarity and, after the existing card.latestPrice = priceMap.get(card.uuid) || null line, a latestPrice shaped { price, finish, provider, asOf, breakdown } (from priceCacheService.getLatestPricesForUUIDsfinish here is already the price-bucket key the pipeline expects, not a display name).

  • Produces: each card object in every affected response gains card.yourPrice: number|nullnull when there's no latestPrice to compute from. No new queries; req.store is already available on every authenticated route via dualModeAuth middleware.

  • [ ] Step 1: Write the failing test

This project's route tests live at the service layer (no supertest). Since this task is a small, mechanical addition repeated at 3-4 sites in one file, verify it via a focused unit test of the shared logic instead of the route: extract nothing new (the computation is one line using already-tested computePriceBreakdown), so write a direct assertion that the pattern works against a realistic latestPrice shape, in a new small test appended to server/services/priceLookupService.test.js:

js
describe('catalog yourPrice pattern (computePriceBreakdown driving card.yourPrice)', () => {
    it('computes yourPrice from a realistic latestPrice + rarity pair', () => {
        const latestPrice = { price: 4.20, finish: 'foil', provider: 'tcgplayer', asOf: '2026-07-12' };
        const config = {
            globalModifier: { enabled: true, value: 1.1, threshold: 49.99 },
            rounding: { enabled: true, decimals: [39, 59, 79, 99] },
            minimumPrices: { enabled: true, global: 0.59, byRarity: {} }
        };
        const { yourPrice } = computePriceBreakdown(latestPrice.price, latestPrice.finish, 'rare', 'nm', config);
        expect(yourPrice).toBeGreaterThan(0);
    });
});
  • [ ] Step 2: Run to verify it fails

Run: npx vitest run server/services/priceLookupService.test.js Expected: PASS already (this test doesn't exercise new code — computePriceBreakdown was built in Task 1). This step is a sanity check that the shape assumption (latestPrice.finish is a bucket key) is correct, not a RED/GREEN cycle — if it fails, stop and re-read priceCacheService.pickDisplayPrice/extractAllPrices before proceeding, since that means the shape assumption this whole task rests on is wrong.

  • [ ] Step 3: Implement — resolve config once per handler, attach yourPrice per card

For each of the four sites (grep for card.latestPrice = priceMap.get), add a config resolution once near the top of that route handler (after req.store is available, before the per-card loop) and the attachment inside the existing per-card loop. Example for the MTG handler (~line 226-294 area — the other three follow the identical shape, just with that handler's own gameId):

js
                // (near the top of this handler, once — not per card)
                const { getGamePricingConfig } = require('../services/pricingConfigService');
                const { computePriceBreakdown } = require('../services/priceLookupService');
                const gamePricingConfig = getGamePricingConfig(req.store, 'mtg');
js
                for (const card of paginatedCards) {
                    card.latestPrice = priceMap.get(card.uuid) || null;
                    card.yourPrice = card.latestPrice
                        ? computePriceBreakdown(
                            card.latestPrice.price,
                            card.latestPrice.finish,
                            (card.rarity || '').toLowerCase(),
                            'nm',
                            gamePricingConfig
                        ).yourPrice
                        : null;
                }

Apply the same two changes (config resolution once per handler + card.yourPrice line in the loop) to the Pokemon handler (use 'pokemon' as the gameId) and the Riftbound handler ('riftbound' — Riftbound's plugin doesn't implement applyPricing/sync, but getGamePricingConfig/computePriceBreakdown don't depend on that; they only need plugin.rarities/plugin.finishes/getDefaultMinimumPrices(), which Riftbound's plugin does declare — confirm this by checking server/plugins/riftbound/index.js has no error before assuming it works, and note in your report if Riftbound's rarities/finishes/minimums aren't actually declared there yet, since that would make this a no-op returning the raw price for that game rather than a broken one).

The fourth site (~line 1030, the single-card detail endpoint) follows the same pattern — read its surrounding handler first to find which game it serves (check the route path and any game variable already in scope) and mirror the change.

  • [ ] Step 4: Run the full server suite

Run: npm test Expected: all pass — this is purely additive to response payloads, no existing test should assert the absence of a yourPrice field, but double check none do (grep -n "yourPrice" server/**/*.test.js before this task should return nothing; after, only your Step 1 test).

  • [ ] Step 5: Commit
bash
git add server/routes/api.js server/services/priceLookupService.test.js
git commit -m "feat(pricing): attach computed yourPrice to catalog card responses"

Task 6: Optional store auth on the catalog routes

Why this task exists: Task 5 attaches card.yourPrice on GET /catalog/cards and GET /catalog/sets, but those two routes are registered in server/routes/api.js BEFORE router.use(dualModeAuth) (~line 1527) — confirmed by grepping router.use(dualModeAuth) and both routes' registration lines, and by checking neither handler reads req.store/req.shop anywhere in its current body. req.store is undefined there today, so yourPrice can only ever reflect plugin defaults, never a merchant's actual saved pricing config — for CatalogSinglesPage.jsx (confirmed via grep -n "api.get(" client/src/pages/catalog/CatalogSinglesPage.jsx, which calls exactly these two routes), that defeats much of the point of the feature. This task closes that gap with a minimal, additive middleware change — it does not modify any existing authentication logic, only wraps it.

Client-side note (verified, not assumed): client/src/utils/api.js's request interceptor (~line 77-128) already attaches Authorization: Bearer <token> and a shop query param to every outgoing request except /user/* routes, regardless of which route is being called. No client change is needed — once these two routes read req.store, they'll start seeing it for every real embedded/standalone session the app already establishes.

Files:

  • Modify: server/middleware/dualModeAuth.js (add optionalDualModeAuth, exported alongside the existing _setDeps/_resetDeps pattern — module.exports = dualModeAuth; module.exports._setDeps = ...; module.exports._resetDeps = ...; — add module.exports.optionalDualModeAuth = optionalDualModeAuth; the same way)
  • Modify: server/middleware/dualModeAuth.test.js
  • Modify: server/routes/api.js (apply optionalDualModeAuth to the two routes Task 5 touched)

Interfaces:

  • Consumes: the existing dualModeAuth(req, res, next) function (unmodified — this task reads it, does not alter its internals or its own tests).

  • Produces: optionalDualModeAuth(req, res, next) — Express middleware. When dualModeAuth would succeed (valid session token or JWT, shop resolves to an active store), behaves identically: sets req.shop/req.store/req.accessToken/req.authMode, calls next(). When dualModeAuth would fail for ANY reason (missing shop param, invalid/expired token, shop mismatch, store not found, etc.), does NOT send an error response and does NOT set req.store — just calls next() with the request otherwise untouched, so the route handler proceeds exactly as it does today for an unauthenticated request.

  • [ ] Step 1: Write the failing tests

Read server/middleware/dualModeAuth.test.js first — reuse its exact _setDeps/mock-object/beforeEach/afterEach scaffolding (do not rebuild it). Add a new top-level describe block:

js
describe('optionalDualModeAuth Middleware', () => {
    // Reuses the same req/res/next/mock setup as the describe block above —
    // if this file's structure nests fixtures inside a single top-level
    // beforeEach, add this describe block as a sibling so it inherits the
    // same req/res/next/mockTryVerify/mockStore/etc. Do not duplicate the
    // fixture setup; import { optionalDualModeAuth } the same way the top
    // of this file imports dualModeAuth.

    it('sets req.store and calls next() when auth succeeds (same as dualModeAuth)', async () => {
        req.query.shop = 'test-shop.myshopify.com';
        req.headers.authorization = 'Bearer validtoken';
        mockTryVerify.mockReturnValue({ shop: 'test-shop.myshopify.com', userId: 'user-1' });
        const storeDoc = { shop: 'test-shop.myshopify.com', isActive: true };
        mockStore.findOne.mockResolvedValue(storeDoc);

        await optionalDualModeAuth(req, res, next);

        expect(req.store).toBe(storeDoc);
        expect(req.shop).toBe('test-shop.myshopify.com');
        expect(next).toHaveBeenCalled();
        expect(res.status).not.toHaveBeenCalled();
    });

    it('calls next() without req.store when shop parameter is missing (dualModeAuth would 400)', async () => {
        await optionalDualModeAuth(req, res, next);

        expect(req.store).toBeUndefined();
        expect(next).toHaveBeenCalled();
        expect(res.status).not.toHaveBeenCalled();
        expect(res.json).not.toHaveBeenCalled();
    });

    it('calls next() without req.store when the session token is invalid and no JWT is present (dualModeAuth would 401)', async () => {
        req.query.shop = 'test-shop.myshopify.com';
        mockTryVerify.mockReturnValue(null);

        await optionalDualModeAuth(req, res, next);

        expect(req.store).toBeUndefined();
        expect(next).toHaveBeenCalled();
        expect(res.status).not.toHaveBeenCalled();
    });

    it('calls next() without req.store when the session token shop does not match the requested shop (dualModeAuth would 403)', async () => {
        req.query.shop = 'test-shop.myshopify.com';
        req.headers.authorization = 'Bearer validtoken';
        mockTryVerify.mockReturnValue({ shop: 'other-shop.myshopify.com', userId: 'user-1' });

        await optionalDualModeAuth(req, res, next);

        expect(req.store).toBeUndefined();
        expect(next).toHaveBeenCalled();
        expect(res.status).not.toHaveBeenCalled();
    });

    it('never calls next() twice for a single request', async () => {
        req.query.shop = 'test-shop.myshopify.com';
        mockTryVerify.mockReturnValue(null);

        await optionalDualModeAuth(req, res, next);

        expect(next).toHaveBeenCalledTimes(1);
    });
});

Update this test file's import line to also pull in optionalDualModeAuth:

js
const { default: dualModeAuth, optionalDualModeAuth, _setDeps, _resetDeps } = await import('./dualModeAuth.js');
  • [ ] Step 2: Run to verify it fails

Run: npx vitest run server/middleware/dualModeAuth.test.js Expected: FAIL — optionalDualModeAuth is undefined.

  • [ ] Step 3: Implement

In server/middleware/dualModeAuth.js, add after the dualModeAuth function definition (do not modify dualModeAuth itself — this wraps it):

js
/**
 * Same authentication logic as dualModeAuth, but never blocks the request.
 * When authentication would succeed, behaves identically (req.store etc.
 * are set). When it would fail for any reason (missing shop, invalid
 * token, shop mismatch, store not found...), silently proceeds without
 * req.store rather than sending an error response — the route handler
 * must already tolerate req.store being undefined (see
 * pricingConfigService.getGamePricingConfig, which degrades to plugin
 * defaults). Used on routes that work for both authenticated merchants
 * (real per-store data) and anonymous/public access (defaults).
 *
 * Implemented as a response shim around the real dualModeAuth rather than
 * a parallel reimplementation, so the actual verification/token-exchange/
 * ownership logic can never drift between the two.
 */
async function optionalDualModeAuth(req, res, next) {
    let handled = false;
    const passthroughRes = {
        status: () => passthroughRes,
        // dualModeAuth's final "no valid auth" branch calls res.set(...)
        // (an App-Bridge-retry header) before its status().json() — this
        // must be a no-op stand-in, not missing, or that branch throws
        // instead of falling through.
        set: () => passthroughRes,
        json: () => {
            if (!handled) {
                handled = true;
                next();
            }
            return passthroughRes;
        }
    };

    await dualModeAuth(req, passthroughRes, () => {
        if (!handled) {
            handled = true;
            next();
        }
    });
}

Update the module's exports (currently module.exports = dualModeAuth; module.exports._setDeps = _setDeps; module.exports._resetDeps = _resetDeps;) to add:

js
module.exports.optionalDualModeAuth = optionalDualModeAuth;

In server/routes/api.js, apply the new middleware to the two routes Task 5 modified (router.get('/catalog/cards', ...) and router.get('/catalog/sets', ...) or whichever function name they route to — re-locate them via grep -n "router.get('/catalog/cards'\|router.get('/catalog/sets'" server/routes/api.js). Import it near the top with the other middleware imports:

js
const { optionalDualModeAuth } = require('../middleware/dualModeAuth');

Insert it as the first middleware argument on both routes, e.g.:

js
router.get('/catalog/cards', optionalDualModeAuth, validate(catalogCardsQuerySchema, { source: 'query' }), async (req, res) => {

(and the equivalent for /catalog/sets). Do not reorder validate(...) relative to where it already is — optionalDualModeAuth goes before it, matching how dualModeAuth precedes route-specific middleware elsewhere in this file.

  • [ ] Step 4: Run to verify it passes

Run: npx vitest run server/middleware/dualModeAuth.test.js Expected: PASS (all new tests plus every pre-existing dualModeAuth test unchanged and still green — confirms the wrapper didn't alter the wrapped function's own behavior/tests).

  • [ ] Step 5: Run the full server suite

Run: npm test Expected: all pass.

  • [ ] Step 6: Update the two config-resolution comments Task 5 added

In server/routes/api.js, find the two comments Task 5 added near its gamePricingConfig resolution (search for req.store is undefined on this public) and update the wording — req.store is no longer always undefined on these routes now, only when the request is genuinely unauthenticated:

js
        // Resolved once for the whole request (not per card). req.store is
        // populated for authenticated sessions (optionalDualModeAuth) and
        // undefined for anonymous/public requests; getGamePricingConfig
        // degrades to the game's plugin-declared defaults either way.
  • [ ] Step 7: Commit
bash
git add server/middleware/dualModeAuth.js server/middleware/dualModeAuth.test.js server/routes/api.js
git commit -m "feat(pricing): resolve the browsing merchant's real store on catalog routes"

Task 7: Client — pricing settings live example

Files:

  • Create: client/src/components/PricingBreakdownList.jsx (shared presentational component — also used by Task 10's catalog breakdown modal)
  • Create: client/src/components/PricingBreakdownList.test.jsx
  • Create: client/src/components/PricingLiveExample.jsx
  • Create: client/src/components/PricingLiveExample.test.jsx
  • Modify: client/src/components/PricingConfigPanel.jsx (mount the new component, ~near the Save button at the bottom of the pricing card)

Interfaces:

  • Consumes: GET /api/preferences?game= (existing, returns { selectedSets: string[] }), GET /api/sets/:code?game=&limit=1 (existing, returns { cards: { data: [{ uuid|sourceCardId, rarity, latestPrice, ... }] } } — reuse whichever id field this game's cards actually have; MTG uses uuid, Pokemon uses sourceCardId, both games' card objects already carry rarity), POST /api/pricing-preview?game= (Task 3).

  • Produces:

    • <PricingBreakdownList pricingBreakdown={Array<{step, applied, before, after}>} /> — presentational, renders the step list with each step dimmed when applied: false. No data fetching of its own; both PricingLiveExample (this task) and PricingBreakdownModal (Task 10) render it from their own fetched result.pricingBreakdown.
    • <PricingLiveExample game={game} pricingConfig={pricingConfig} /> — a self-contained card that: on mount (and whenever game changes), fetches a default sample card (first selected set → first card in it); whenever pricingConfig (the parent's unsaved form state) changes, debounces ~500ms then POSTs to /pricing-preview with configOverride: pricingConfig for that sample card; renders market price, your price, and <PricingBreakdownList pricingBreakdown={result.pricingBreakdown} />.
  • [ ] Step 1: Write the failing test for the shared breakdown-list component

jsx
// client/src/components/PricingBreakdownList.test.jsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import PricingBreakdownList from './PricingBreakdownList';

describe('PricingBreakdownList', () => {
    const pricingBreakdown = [
        { 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: 'global' }
    ];

    it('renders one row per step with a human-readable label', () => {
        render(<PricingBreakdownList pricingBreakdown={pricingBreakdown} />);
        expect(screen.getByText('Markup')).toBeInTheDocument();
        expect(screen.getByText('Condition')).toBeInTheDocument();
        expect(screen.getByText('Rounding')).toBeInTheDocument();
        expect(screen.getByText('Minimum')).toBeInTheDocument();
    });

    it('dims a step marked applied: false', () => {
        render(<PricingBreakdownList pricingBreakdown={pricingBreakdown} />);
        const conditionRow = screen.getByText('Condition').closest('[data-testid="pricing-step"]');
        expect(conditionRow).toHaveAttribute('data-applied', 'false');
        expect(conditionRow.className).toMatch(/opacity-40/);
    });

    it('does not dim an applied step', () => {
        render(<PricingBreakdownList pricingBreakdown={pricingBreakdown} />);
        const modifierRow = screen.getByText('Markup').closest('[data-testid="pricing-step"]');
        expect(modifierRow).toHaveAttribute('data-applied', 'true');
        expect(modifierRow.className).not.toMatch(/opacity-40/);
    });
});
  • [ ] Step 2: Run to verify it fails

Run: npx vitest run --config vitest.client.config.js client/src/components/PricingBreakdownList.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Implement the shared component
jsx
// client/src/components/PricingBreakdownList.jsx
/**
 * PricingBreakdownList
 *
 * Presentational: renders a pricingBreakdown array (from
 * computePriceBreakdown, via POST /pricing-preview) as a step-by-step
 * list, dimming steps that didn't apply. Shared by PricingLiveExample
 * (settings page live example) and PricingBreakdownModal (catalog
 * on-demand breakdown) — no data fetching of its own.
 */
import { Text } from '@/components/retroui';
import { formatPrice } from '../utils/format';

const STEP_LABELS = {
    modifier: 'Markup',
    condition: 'Condition',
    rounding: 'Rounding',
    minimum: 'Minimum'
};

export default function PricingBreakdownList({ pricingBreakdown }) {
    return (
        <div className="space-y-1">
            {pricingBreakdown.map((step) => (
                <div
                    key={step.step}
                    data-testid="pricing-step"
                    data-applied={String(step.applied)}
                    className={`flex justify-between text-sm ${step.applied ? '' : 'opacity-40'}`}
                >
                    <Text as="span">{STEP_LABELS[step.step] || step.step}</Text>
                    <Text as="span">{formatPrice(step.before)} → {formatPrice(step.after)}</Text>
                </div>
            ))}
        </div>
    );
}
  • [ ] Step 4: Run to verify it passes

Run: npx vitest run --config vitest.client.config.js client/src/components/PricingBreakdownList.test.jsx Expected: PASS

  • [ ] Step 5: Commit the shared component on its own
bash
git add client/src/components/PricingBreakdownList.jsx client/src/components/PricingBreakdownList.test.jsx
git commit -m "feat(pricing): add shared PricingBreakdownList presentational component"
  • [ ] Step 6: Write the failing tests for PricingLiveExample
jsx
// client/src/components/PricingLiveExample.test.jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';

vi.mock('../utils/api', () => ({ default: { get: vi.fn(), post: vi.fn() } }));

import api from '../utils/api';
import PricingLiveExample from './PricingLiveExample';

describe('PricingLiveExample', () => {
    beforeEach(() => {
        vi.clearAllMocks();
        vi.useFakeTimers({ shouldAdvanceTime: true });
        api.get.mockImplementation((path) => {
            if (path === '/preferences') return Promise.resolve({ data: { selectedSets: ['MKM'] } });
            if (path === '/sets/MKM') return Promise.resolve({
                data: { cards: { data: [{ uuid: 'card-1', rarity: 'rare' }] } }
            });
            return Promise.resolve({ data: {} });
        });
        api.post.mockResolvedValue({
            data: {
                results: [{
                    id: 'card-1', finish: 'normal', marketPrice: 4.20, source: 'tcgplayer',
                    timestamp: '2026-07-12', yourPrice: 4.99,
                    pricingBreakdown: [
                        { 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: 'global' }
                    ]
                }]
            }
        });
    });

    it('loads a default sample card and shows its breakdown', async () => {
        render(<PricingLiveExample game="mtg" pricingConfig={{ globalModifier: { value: 1.1 } }} />);
        await vi.advanceTimersByTimeAsync(600);
        await waitFor(() => expect(screen.getByText('$4.20')).toBeInTheDocument());
        expect(screen.getByText('$4.99')).toBeInTheDocument();
    });

    it('debounces re-fetching when pricingConfig changes rapidly', async () => {
        const { rerender } = render(<PricingLiveExample game="mtg" pricingConfig={{ globalModifier: { value: 1.1 } }} />);
        await vi.advanceTimersByTimeAsync(600);
        const callsAfterInitialLoad = api.post.mock.calls.length;

        rerender(<PricingLiveExample game="mtg" pricingConfig={{ globalModifier: { value: 1.2 } }} />);
        rerender(<PricingLiveExample game="mtg" pricingConfig={{ globalModifier: { value: 1.3 } }} />);
        rerender(<PricingLiveExample game="mtg" pricingConfig={{ globalModifier: { value: 1.4 } }} />);
        await vi.advanceTimersByTimeAsync(600);

        // Only one additional preview call for the whole burst of rapid edits.
        expect(api.post.mock.calls.length).toBe(callsAfterInitialLoad + 1);
        expect(api.post).toHaveBeenLastCalledWith(
            '/pricing-preview',
            expect.objectContaining({ configOverride: { globalModifier: { value: 1.4 } } }),
            { params: { game: 'mtg' } }
        );
    });

    it('dims a step marked applied: false', async () => {
        render(<PricingLiveExample game="mtg" pricingConfig={{}} />);
        await vi.advanceTimersByTimeAsync(600);
        await waitFor(() => expect(screen.getByText('Condition')).toBeInTheDocument());
        const conditionRow = screen.getByText('Condition').closest('[data-testid="pricing-step"]');
        expect(conditionRow).toHaveAttribute('data-applied', 'false');
    });
});
  • [ ] Step 7: Run to verify it fails

Run: npx vitest run --config vitest.client.config.js client/src/components/PricingLiveExample.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 8: Implement, using the shared PricingBreakdownList from Step 3
jsx
// client/src/components/PricingLiveExample.jsx
/**
 * PricingLiveExample
 *
 * Live worked example inside the pricing settings card: shows one sample
 * card's market price -> your price, with a step-by-step breakdown, so a
 * merchant can see what their (unsaved) pricing rules actually do before
 * clicking Save. Recomputes against the parent's unsaved pricingConfig via
 * POST /pricing-preview + configOverride (never persisted), debounced.
 */
import { useEffect, useRef, useState } from 'react';
import api from '../utils/api';
import { Card, Text } from '@/components/retroui';
import { formatPrice } from '../utils/format';
import PricingBreakdownList from './PricingBreakdownList';

const DEBOUNCE_MS = 500;

async function loadSampleCard(game) {
    const { data: prefs } = await api.get('/preferences', { params: { game } });
    const setCode = prefs.selectedSets?.[0];
    if (!setCode) return null;

    const { data } = await api.get(`/sets/${setCode}`, { params: { game, limit: 1 } });
    const card = data?.cards?.data?.[0];
    if (!card) return null;

    return {
        id: card.uuid || card.sourceCardId,
        rarity: (card.rarity || 'common').toLowerCase(),
        finish: 'normal'
    };
}

export default function PricingLiveExample({ game, pricingConfig }) {
    const [sampleCard, setSampleCard] = useState(null);
    const [result, setResult] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);
    const debounceRef = useRef(null);

    // Load the sample card once per game (independent of pricingConfig edits).
    useEffect(() => {
        let ignore = false;
        setLoading(true);
        loadSampleCard(game)
            .then((card) => { if (!ignore) setSampleCard(card); })
            .catch((err) => { if (!ignore) setError(err.message || 'Failed to load a sample card'); })
            .finally(() => { if (!ignore) setLoading(false); });
        return () => { ignore = true; };
    }, [game]);

    // Debounced re-preview whenever the unsaved form state changes.
    useEffect(() => {
        if (!sampleCard) return;
        if (debounceRef.current) clearTimeout(debounceRef.current);

        debounceRef.current = setTimeout(async () => {
            try {
                const { data } = await api.post(
                    '/pricing-preview',
                    { cards: [sampleCard], configOverride: pricingConfig },
                    { params: { game } }
                );
                setResult(data.results[0]);
                setError(null);
            } catch (err) {
                setError(err.response?.data?.error || 'Failed to compute preview');
            }
        }, DEBOUNCE_MS);

        return () => clearTimeout(debounceRef.current);
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [sampleCard, pricingConfig, game]);

    if (loading) {
        return (
            <Card className="p-3 sm:p-4">
                <Text as="p" className="text-sm text-muted-foreground">Loading live example...</Text>
            </Card>
        );
    }

    if (!sampleCard) {
        return (
            <Card className="p-3 sm:p-4">
                <Text as="p" className="text-sm text-muted-foreground">
                    Select a set to see a live pricing example.
                </Text>
            </Card>
        );
    }

    return (
        <Card className="p-3 sm:p-4 space-y-3">
            <Text as="h4" className="text-base font-medium">Live Example</Text>
            {error && <Text as="p" className="text-sm text-red-600">{error}</Text>}
            {result && (
                <div className="space-y-2">
                    <div className="flex items-center gap-4">
                        <div>
                            <Text as="p" className="text-xs text-muted-foreground">Market</Text>
                            <Text as="p" className="text-lg font-bold">{formatPrice(result.marketPrice)}</Text>
                        </div>
                        <Text as="span" className="text-muted-foreground">→</Text>
                        <div>
                            <Text as="p" className="text-xs text-muted-foreground">Your Price</Text>
                            <Text as="p" className="text-lg font-bold">{formatPrice(result.yourPrice)}</Text>
                        </div>
                    </div>
                    <PricingBreakdownList pricingBreakdown={result.pricingBreakdown} />
                </div>
            )}
        </Card>
    );
}

Read client/src/components/PricingConfigPanel.jsx before editing it — mount <PricingLiveExample game={game} pricingConfig={pricingConfig} /> inside the existing card, near the Save button, so it sits within the same !pricingLoading render branch (it needs pricingConfig to already be loaded). Import it at the top: import PricingLiveExample from './PricingLiveExample';.

  • [ ] Step 9: Run to verify it passes

Run: npx vitest run --config vitest.client.config.js client/src/components/PricingLiveExample.test.jsx Expected: PASS

  • [ ] Step 10: Run the full client suite + build

Run: npm run test:client -- --run then npm run build Expected: PASS / clean (per the Global Constraints note on flaky full-suite runs — if something unrelated to this task flakes, confirm via isolated re-run before treating it as a regression).

  • [ ] Step 11: Commit
bash
git add client/src/components/PricingLiveExample.jsx client/src/components/PricingLiveExample.test.jsx client/src/components/PricingConfigPanel.jsx
git commit -m "feat(pricing): add live pricing example to the settings panel"

Task 8: Client — PreviewModal Market/Your Price columns

Files:

  • Modify: client/src/components/PreviewModal.jsx (table header ~line 293, flattening logic ~lines 65-93, price cell ~line 355)
  • Modify: client/src/components/PreviewModal.test.jsx

Interfaces:

  • Consumes: product.variants[].marketPrice / product.variantMarketPrice (Task 4 — already present on the API response previewSyncProducts() returns, no client-side computation needed).

  • Produces: the table's single "Price" column becomes two columns, "Market" and "Your Price"; a row whose product is pre-sale (product._isPreSale || product._status === 'DRAFT') shows a "No market data" badge in the Market column instead of the $999.99 placeholder.

  • [ ] Step 1: Write the failing test

Read client/src/components/PreviewModal.test.jsx first to match its existing render/mock conventions (it already renders PreviewModal with a previewData prop shaped like the real API response). Add:

jsx
it('shows separate Market and Your Price columns', () => {
    const previewData = {
        products: [{
            _id: 'p1',
            metafields: { card_name: 'Test Card', rarity: 'rare' },
            variants: [{ finish: 'Normal', sku: 'sku-1', price: 4.99, marketPrice: 4.20 }]
        }],
        stats: { total: 1 }
    };
    render(<PreviewModal open previewData={previewData} syncConfig={{}} onClose={() => {}} onProceedToSync={() => {}} />);
    expect(screen.getByText('$4.20')).toBeInTheDocument();
    expect(screen.getByText('$4.99')).toBeInTheDocument();
});

it('shows a "No market data" badge instead of the placeholder price for pre-sale rows', () => {
    const previewData = {
        products: [{
            _id: 'p2',
            _isPreSale: true,
            _status: 'DRAFT',
            metafields: { card_name: 'Presale Card', rarity: 'rare' },
            variants: [{ finish: 'Normal', sku: 'sku-2', price: 999.99, marketPrice: null }]
        }],
        stats: { total: 1 }
    };
    render(<PreviewModal open previewData={previewData} syncConfig={{}} onClose={() => {}} onProceedToSync={() => {}} />);
    expect(screen.getByText('No market data')).toBeInTheDocument();
    expect(screen.queryByText('$999.99')).not.toBeInTheDocument();
});
  • [ ] Step 2: Run to verify it fails

Run: npx vitest run --config vitest.client.config.js client/src/components/PreviewModal.test.jsx Expected: FAIL — only one price column currently exists; no "No market data" badge.

  • [ ] Step 3: Implement

In the flattenedProducts useMemo (~line 65-93), add variantMarketPrice: variant.marketPrice next to the existing variantprice: variant.price in the per-variant branch; the non-variant (Pokemon-shaped) branch already spreads ...product, which now includes product.variantMarketPrice from Task 4 — no change needed there.

Split the header (~line 293):

jsx
                      <th className="text-left px-2 sm:px-3 md:px-4 py-2 font-semibold whitespace-nowrap">Market</th>
                      <th className="text-left px-2 sm:px-3 md:px-4 py-2 font-semibold whitespace-nowrap">Your Price</th>

Split the price cell (~line 354-356):

jsx
                        <td className="px-2 sm:px-3 md:px-4 py-2 whitespace-nowrap">
                          {(product._isPreSale || product._status === 'DRAFT')
                            ? <Badge variant="warning">No market data</Badge>
                            : formatPrice(product.variantMarketPrice, '-')}
                        </td>
                        <td className="px-2 sm:px-3 md:px-4 py-2 whitespace-nowrap">
                          {formatPrice(product.variantprice, '-')}
                        </td>
  • [ ] Step 4: Run to verify it passes

Run: npx vitest run --config vitest.client.config.js client/src/components/PreviewModal.test.jsx Expected: PASS

  • [ ] Step 5: Run the full client suite + build

Run: npm run test:client -- --run then npm run build

  • [ ] Step 6: Commit
bash
git add client/src/components/PreviewModal.jsx client/src/components/PreviewModal.test.jsx
git commit -m "feat(pricing): show market vs your price columns in the sync preview modal"

Task 9: Client — catalog "Your Price" column

Files:

  • Modify: client/src/pages/catalog/CatalogSinglesPage.jsx (grid view price ~line 330-337, table view header ~line 382-390, table view price ~line 420-428)
  • Modify: client/src/pages/catalog/CatalogSinglesPage.test.jsx

Interfaces:

  • Consumes: card.yourPrice (Task 5 — already present on GET /api/sets/:code responses, no client-side computation needed).

  • Produces: both the grid and table views show a "Your Price" value next to the existing market card.latestPrice value. This task ships the number only; Task 10 adds the on-demand breakdown affordance on top of it.

  • [ ] Step 1: Write the failing test

Read client/src/pages/catalog/CatalogSinglesPage.test.jsx first to match its existing render/mock conventions. Add:

jsx
it('shows a Your Price value next to the market price', async () => {
    api.get.mockResolvedValue({
        data: {
            cards: [{ uuid: 'c1', name: 'Test Card', rarity: 'rare', latestPrice: { price: 4.20, finish: 'normal' }, yourPrice: 4.99 }],
            pagination: { page: 1, pages: 1, total: 1 }
        }
    });
    renderAt('mtg');
    await waitFor(() => expect(screen.getByText('$4.20')).toBeInTheDocument());
    expect(screen.getByText('$4.99')).toBeInTheDocument();
});

(Use whatever the file's existing renderAt/render helper is called — do not invent a new one if one already exists.)

  • [ ] Step 2: Run to verify it fails

Run: npx vitest run --config vitest.client.config.js client/src/pages/catalog/CatalogSinglesPage.test.jsx Expected: FAIL — $4.99 not rendered anywhere yet.

  • [ ] Step 3: Implement

At each of the two price-rendering sites (~line 333-336 grid view, ~line 424-427 table view), add a sibling "Your Price" value next to the existing market-price rendering. Example for the table view (mirror for the grid view using that view's existing JSX structure/classes):

jsx
                        <td className="px-3 py-2 border-b border-black/10">
                          <div
                            className={`font-bold ${card.latestPrice ? 'text-foreground' : 'text-muted-foreground'}`}
                            title={buildPriceTooltip(card.latestPrice)}
                          >
                            {card.latestPrice ? formatPrice(card.latestPrice.price, '—') : '—'}
                          </div>
                          <div className="text-xs text-muted-foreground">
                            Your price: {card.yourPrice != null ? formatPrice(card.yourPrice, '—') : '—'}
                          </div>
                        </td>

Add the table header column split (~line 389) if the market and your-price values don't fit under one "Price" header cleanly — check the current header row first; a single "Price" header spanning both stacked values (as shown above) is fine and requires no header change, since "Your Price" renders as a sub-line under the existing cell rather than a new column. Prefer that (simpler diff) unless the existing row layout makes stacking visually cramped, in which case split into two <th>s ("Market" / "Your Price") matching Task 8's pattern.

  • [ ] Step 4: Run to verify it passes

Run: npx vitest run --config vitest.client.config.js client/src/pages/catalog/CatalogSinglesPage.test.jsx Expected: PASS

  • [ ] Step 5: Run the full client suite + build

Run: npm run test:client -- --run then npm run build

  • [ ] Step 6: Commit
bash
git add client/src/pages/catalog/CatalogSinglesPage.jsx client/src/pages/catalog/CatalogSinglesPage.test.jsx
git commit -m "feat(pricing): show computed Your Price alongside market price in the catalog"

Task 10: Client — catalog on-demand breakdown modal

Files:

  • Create: client/src/components/PricingBreakdownModal.jsx
  • Create: client/src/components/PricingBreakdownModal.test.jsx
  • Modify: client/src/pages/catalog/CatalogSinglesPage.jsx (info-icon trigger next to "Your Price", modal state + mount — follow the exact existing pattern the file already uses for PriceHistoryModal: a priceModalOpen/priceModalCard state pair, a handleViewPrices(card) handler, and one modal instance mounted at the bottom of the component near <PriceHistoryModal ... />)

Interfaces:

  • Consumes: PricingBreakdownList (Task 7), POST /api/pricing-preview?game= (Task 3 — called here with no `configOverride**, since this uses the store's saved config, not an unsaved form state).

  • Produces: <PricingBreakdownModal card= game={game} open={boolean} onClose={fn} /> — controlled modal (same open/onClose contract as PriceHistoryModal). Fetches the breakdown for that one card when it opens; shows loading/error states; renders market price, your price, and <PricingBreakdownList pricingBreakdown={...} />.

  • [ ] Step 1: Write the failing test

jsx
// client/src/components/PricingBreakdownModal.test.jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';

vi.mock('../utils/api', () => ({ default: { post: vi.fn() } }));

import api from '../utils/api';
import PricingBreakdownModal from './PricingBreakdownModal';

describe('PricingBreakdownModal', () => {
    beforeEach(() => {
        vi.clearAllMocks();
    });

    it('does not fetch when closed', () => {
        render(
            <PricingBreakdownModal
                card={{ id: 'card-1', finish: 'normal', rarity: 'rare' }}
                game="mtg"
                open={false}
                onClose={() => {}}
            />
        );
        expect(api.post).not.toHaveBeenCalled();
    });

    it('fetches and renders the breakdown when opened', async () => {
        api.post.mockResolvedValue({
            data: {
                results: [{
                    id: 'card-1', finish: 'normal', marketPrice: 4.20, source: 'tcgplayer',
                    timestamp: '2026-07-12', yourPrice: 4.99,
                    pricingBreakdown: [
                        { step: 'modifier', applied: true, before: 4.20, after: 4.62 },
                        { step: 'minimum', applied: false, before: 4.62, after: 4.62 }
                    ]
                }]
            }
        });

        render(
            <PricingBreakdownModal
                card={{ id: 'card-1', finish: 'normal', rarity: 'rare' }}
                game="mtg"
                open
                onClose={() => {}}
            />
        );

        expect(api.post).toHaveBeenCalledWith(
            '/pricing-preview',
            { cards: [{ id: 'card-1', finish: 'normal', rarity: 'rare' }] },
            { params: { game: 'mtg' } }
        );
        await waitFor(() => expect(screen.getByText('$4.20')).toBeInTheDocument());
        expect(screen.getByText('$4.99')).toBeInTheDocument();
        expect(screen.getByText('Markup')).toBeInTheDocument();
    });

    it('shows an error message when the request fails', async () => {
        api.post.mockRejectedValue({ response: { data: { error: 'boom' } } });
        render(
            <PricingBreakdownModal
                card={{ id: 'card-1', finish: 'normal', rarity: 'rare' }}
                game="mtg"
                open
                onClose={() => {}}
            />
        );
        await waitFor(() => expect(screen.getByText('boom')).toBeInTheDocument());
    });
});
  • [ ] Step 2: Run to verify it fails

Run: npx vitest run --config vitest.client.config.js client/src/components/PricingBreakdownModal.test.jsx Expected: FAIL — module doesn't exist.

  • [ ] Step 3: Implement

Read client/src/components/PriceHistoryModal.jsx first — mirror its Dialog/open/onClose/loading/error pattern exactly (same @radix-ui/react-dialog import style, same Cross2Icon close button, same overall structure) rather than inventing a new modal shape:

jsx
// client/src/components/PricingBreakdownModal.jsx
/**
 * PricingBreakdownModal
 *
 * On-demand pricing breakdown for one catalog card, using the store's
 * saved pricing config (no configOverride — unlike PricingLiveExample,
 * which previews unsaved settings-page edits). Same controlled
 * open/onClose contract as PriceHistoryModal.
 */
import { useEffect, useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { Cross2Icon } from '@radix-ui/react-icons';
import { Text } from '@/components/retroui';
import api from '../utils/api';
import { formatPrice } from '../utils/format';
import PricingBreakdownList from './PricingBreakdownList';

export default function PricingBreakdownModal({ card, game, open, onClose }) {
    const [result, setResult] = useState(null);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState(null);

    useEffect(() => {
        if (!open || !card) return;
        setLoading(true);
        setError(null);
        setResult(null);
        api.post('/pricing-preview', { cards: [card] }, { params: { game } })
            .then(({ data }) => setResult(data.results[0]))
            .catch((err) => setError(err.response?.data?.error || 'Failed to load pricing breakdown'))
            .finally(() => setLoading(false));
    }, [open, card, game]);

    return (
        <Dialog.Root open={open} onOpenChange={(next) => { if (!next) onClose(); }}>
            <Dialog.Portal>
                <Dialog.Overlay className="fixed inset-0 bg-black/50" />
                <Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-background border-2 border-black p-4 max-w-sm w-full space-y-3">
                    <div className="flex items-center justify-between">
                        <Dialog.Title asChild><Text as="h3" className="text-base font-medium">Pricing Breakdown</Text></Dialog.Title>
                        <Dialog.Close asChild>
                            <button aria-label="Close" onClick={onClose}><Cross2Icon /></button>
                        </Dialog.Close>
                    </div>
                    {loading && <Text as="p" className="text-sm text-muted-foreground">Loading...</Text>}
                    {error && <Text as="p" className="text-sm text-red-600">{error}</Text>}
                    {result && (
                        <div className="space-y-2">
                            <div className="flex items-center gap-4">
                                <div>
                                    <Text as="p" className="text-xs text-muted-foreground">Market</Text>
                                    <Text as="p" className="text-lg font-bold">{formatPrice(result.marketPrice)}</Text>
                                </div>
                                <Text as="span" className="text-muted-foreground">→</Text>
                                <div>
                                    <Text as="p" className="text-xs text-muted-foreground">Your Price</Text>
                                    <Text as="p" className="text-lg font-bold">{formatPrice(result.yourPrice)}</Text>
                                </div>
                            </div>
                            <PricingBreakdownList pricingBreakdown={result.pricingBreakdown} />
                        </div>
                    )}
                </Dialog.Content>
            </Dialog.Portal>
        </Dialog.Root>
    );
}

In client/src/pages/catalog/CatalogSinglesPage.jsx: read the existing priceModalOpen/priceModalCard/handleViewPrices trio first (added in an earlier feature — grep for them), then add an analogous breakdownModalOpen/breakdownModalCard state pair and a handleViewBreakdown(card) handler. Add a small info-icon button next to the "Your Price" line from Task 9 (reuse InfoCircledIcon from @radix-ui/react-icons, already used elsewhere in this codebase's pricing UI):

jsx
                          <button
                            onClick={() => handleViewBreakdown(card)}
                            className="text-muted-foreground hover:text-foreground"
                            title="View pricing breakdown"
                            aria-label="View pricing breakdown"
                          >
                            <InfoCircledIcon className="h-3.5 w-3.5" />
                          </button>

Mount one <PricingBreakdownModal> instance near the existing <PriceHistoryModal ... /> mount point:

jsx
      <PricingBreakdownModal
        card={breakdownModalCard}
        game={selectedGame}
        open={breakdownModalOpen}
        onClose={() => {
          setBreakdownModalOpen(false);
          setBreakdownModalCard(null);
        }}
      />

breakdownModalCard should be shaped { id: card.uuid || card.sourceCardId, finish: 'normal', rarity: (card.rarity || 'common').toLowerCase() } — build this inside handleViewBreakdown, not inline in the JSX.

  • [ ] Step 4: Run to verify it passes

Run: npx vitest run --config vitest.client.config.js client/src/components/PricingBreakdownModal.test.jsx client/src/pages/catalog/CatalogSinglesPage.test.jsx Expected: PASS

  • [ ] Step 5: Run the full client suite + build

Run: npm run test:client -- --run then npm run build

  • [ ] Step 6: Commit
bash
git add client/src/components/PricingBreakdownModal.jsx client/src/components/PricingBreakdownModal.test.jsx client/src/pages/catalog/CatalogSinglesPage.jsx
git commit -m "feat(pricing): add on-demand pricing breakdown modal to catalog rows"

Task 11: Verification sweep + docs

Files:

  • Modify: whatever the sweep surfaces

  • Modify: CLAUDE.md / server/CLAUDE.md (pricing pipeline notes mention the new transparency primitive/endpoint)

  • [ ] Step 1: Full server suite with coverage

Run: npm run test:coverage Expected: PASS, 70%+ on modified files.

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

Run: npm run test:client -- --run && npm run lint && npm run build Expected: all clean (0 new lint errors — pre-existing warnings are not in scope).

  • [ ] Step 3: Grep for the naming-collision risk

Run: grep -rn "\.breakdown\b" server client --include="*.js" --include="*.jsx" | grep -v "pricingBreakdown\|extractAllPrices\|priceCacheService\|\.test\." Expected: no hits that mix up the two breakdown concepts (a few expected hits in priceCacheService.js/format.js for the pre-existing raw-price breakdown are fine).

  • [ ] Step 4: Update docs

In server/CLAUDE.md's "Multi-Stage Pricing" section, add one line noting computePriceBreakdown in priceLookupService.js as the display-layer twin of calculatePriceWithCondition (same math, step-by-step), and POST /api/pricing-preview in the API Endpoints list.

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

Post-plan reminders (not tasks)

  • Deferred per the spec: overlaying "your price" on the PriceHistoryModal chart, condition-level breakdowns in the catalog (catalog assumes NM throughout this plan), sealed-product transparency.
  • The pricing settings live example's sample-card selection (Task 7) only implements the "default: first card of the first selected set" path from the spec — the "search box to pick any card" enhancement is out of scope for this plan; if wanted, it's a small follow-up building on the same PricingLiveExample component.