Skip to content

Sealed Buylist Support 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: Let merchants and customers quote factory-sealed products (booster boxes, bundles, decks) on buy orders, alongside singles, across MTG and Pokemon.

Architecture: Add a lineType: 'single' | 'sealed' discriminator to the existing BuylistOrder line schema so one order holds both. Sealed lines are priced by a new selector-only path in the quote engine that resolves identity from the global sealed source catalog (plugin.findSealedProducts) and basis price from plugin.preloadSealedPrices (market retail + MSRP fallback), then applies a per-game sealed rate config (flat defaultRate + per-category overrides). No condition/rarity/finish for sealed. Both the merchant tab and the public portal share the same engine.

Tech Stack: Node.js/Express (CommonJS), Mongoose, Zod, Vitest (ESM tests). React 18 + Vite + Tailwind + retroui. Per-game plugin layer in server/plugins/.

Global Constraints ​

  • Never default game (CLAUDE.md Β§5.5) β€” no || 'mtg' anywhere. lineType MAY default to 'single' (a type tag, not an identity param; every legacy line is a single).
  • New sub-document fields declared field-by-field + round-trip test (CLAUDE.md Β§9, Β§5.2).
  • Data-matching literals cite their source (CLAUDE.md Β§5.4). The sealed category vocabulary is Object.keys(CATEGORY_LABELS_SERVER) in server/utils/sealedProductUtils.js (the values categorizeProduct(name) emits). Do not invent category keys.
  • Per-game parity (CLAUDE.md Β§5.1): run the game-parity skill before commit. Every task touching per-game behavior names mtg / pokemon / riftbound as mirrored or exempt. plugin.supportsSealed is true for all three games (sealed buylist itself is not gated per-game); pokemon and riftbound are exempt only from per-category rates, gated by plugin.hasSealedCategoryTaxonomy (false for both β€” no SEALED_CATEGORIES vocabulary for their sealed products), so their sealed lines price at sealed.defaultRate.
  • Server enforces; client mirrors (CLAUDE.md rule 7): sealed enablement and line validation live in Zod + route; the client copies are UX only.
  • Never trust client-echoed identity (rule 7): for a sealed line only sealedUuid, game, quantity are trusted; category/name/setCode are re-derived server-side.
  • Aggregations $project before $sort (CLAUDE.md Β§5.6); never rely on allowDiskUse.
  • All Shopify calls via shopifyAPI.js (rule 6) β€” not relevant to this plan (no Shopify writes), but do not add any.
  • Sealed category taxonomy exists for MTG only. Pokemon and Riftbound sealed have no category; their sealed lines carry category: null and fall through to sealed.defaultRate. This is expected, not a bug.
  • Spec: docs/superpowers/specs/2026-07-23-sealed-buylist-support-design.md.

File Structure ​

Server (create/modify):

  • server/models/BuylistOrder.js β€” add lineType, sealedUuid, category to buylistLineSchema.
  • server/utils/sealedProductUtils.js β€” export SEALED_CATEGORIES (ordered key list).
  • server/services/buylistConfigService.js β€” sealed defaults block; sealed vocabulary in the config response; sealed-enablement gating in resolveLineGameConfigs.
  • server/schemas/buylistConfig.js β€” sealed object in the update schema.
  • server/schemas/buylistOrder.js β€” selectedCardLineSchema becomes a single|sealed union.
  • server/services/buylistQuoteService.js β€” findSealedByUuid, resolveSealedBasisPrice, applySealedBuylistRate, priceIdentifiedSealedLine; priceSelectedLines branches on lineType.
  • server/plugins/index.js β€” getGamesInfo() includes supportsSealed.
  • server/schemas/catalog.js β€” catalogSealedQuerySchema gains optional search.
  • server/routes/api.js β€” /catalog/sealed name-search filtering in all three branches; POST /buylist/orders already routes through the updated service.
  • server/services/publicBuylistService.js β€” inherits sealed via the shared engine + resolveLineGameConfigs; toPublicOrder surfaces lineType/category.

Client (create/modify):

  • client/src/pages/buylist/SealedSearchPicker.jsx β€” new sealed name-search picker.
  • client/src/pages/buylist/NewBuyInTab.jsx β€” Singles/Sealed toggle, addSealed, sealed cart rows.
  • client/src/pages/portal/BuylistPortalPage.jsx β€” same treatment.
  • client/src/pages/buylist/BuylistReviewPage.jsx, client/src/pages/buylist/BuylistOrdersTab.jsx β€” sealed chip.
  • client/src/pages/buylist/BuylistSettingsTab.jsx β€” sealed rate section.

Task 1: Line schema β€” lineType, sealedUuid, category ​

Files:

  • Modify: server/models/BuylistOrder.js:12-30 (buylistLineSchema)
  • Test: server/models/BuylistOrder.test.js

Interfaces:

  • Produces: buylistLineSchema accepts lineType: 'single'|'sealed' (default 'single'), sealedUuid: String, category: String. Existing single fields unchanged.

  • [ ] Step 1: Write the failing round-trip test

Add to server/models/BuylistOrder.test.js (follow the file's existing import/connect pattern):

js
it('persists sealed line fields (lineType, sealedUuid, category)', async () => {
  const order = await BuylistOrder.create({
    shop: 'test.myshopify.com',
    source: 'merchant',
    customer: { email: 'seller@example.com' },
    lines: [{
      rawLine: '1 Foundations Play Booster Box [FDN]',
      matchStatus: 'matched',
      game: 'mtg',
      lineType: 'sealed',
      sealedUuid: 'abc-uuid-123',
      category: 'booster_box',
      cardName: 'Foundations Play Booster Box',
      setCode: 'FDN',
      quantity: 1,
      basisPrice: 100,
      offerPrice: 60,
      included: true
    }]
  });
  const read = await BuylistOrder.findById(order._id).lean();
  expect(read.lines[0].lineType).toBe('sealed');
  expect(read.lines[0].sealedUuid).toBe('abc-uuid-123');
  expect(read.lines[0].category).toBe('booster_box');
});

it('defaults lineType to single for a line that omits it', async () => {
  const order = await BuylistOrder.create({
    shop: 'test.myshopify.com',
    source: 'merchant',
    customer: { email: 'seller@example.com' },
    lines: [{ rawLine: '1 Sol Ring', matchStatus: 'matched', game: 'mtg', quantity: 1 }]
  });
  const read = await BuylistOrder.findById(order._id).lean();
  expect(read.lines[0].lineType).toBe('single');
});
  • [ ] Step 2: Run the test to verify it fails

Run: npm test -- BuylistOrder Expected: FAIL β€” lineType/sealedUuid/category are dropped by strict mode (undefined), first assertion fails.

  • [ ] Step 3: Add the fields to the schema

In server/models/BuylistOrder.js, inside buylistLineSchema, add after the game field (line 16):

js
    // Discriminates a single-card line from a sealed-product line. Defaults
    // to 'single' β€” every line written before sealed support existed is a
    // single (this is a type tag, not a Β§5.5 identity default).
    lineType: { type: String, enum: ['single', 'sealed'], default: 'single' },
    // Sealed identity key (MTGJSON sealed uuid / String(tcgplayerProductId)).
    // Set on sealed lines only; singles use cardUuid.
    sealedUuid: String,
    // Sealed product category (Object.keys(CATEGORY_LABELS_SERVER) in
    // server/utils/sealedProductUtils.js). Re-derived server-side. MTG only;
    // Pokemon/Riftbound sealed lines leave this unset.
    category: String,
  • [ ] Step 4: Run the test to verify it passes

Run: npm test -- BuylistOrder Expected: PASS (both new tests + existing tests).

  • [ ] Step 5: Commit
bash
git add server/models/BuylistOrder.js server/models/BuylistOrder.test.js
git commit -m "Add sealed line fields to buylist order schema"

Task 2: SEALED_CATEGORIES vocabulary export ​

Files:

  • Modify: server/utils/sealedProductUtils.js:110 (module.exports)
  • Test: server/utils/sealedProductUtils.test.js (create if absent)

Interfaces:

  • Produces: SEALED_CATEGORIES: string[] β€” ordered category keys, the single source consumed by the config schema (Task 4) and config response (Task 3).

  • [ ] Step 1: Write the failing test

Create/append server/utils/sealedProductUtils.test.js:

js
import { describe, it, expect } from 'vitest';
import pkg from './sealedProductUtils.js';
const { SEALED_CATEGORIES, CATEGORY_LABELS_SERVER, categorizeProduct } = pkg;

describe('SEALED_CATEGORIES', () => {
  it('is exactly the CATEGORY_LABELS_SERVER keys', () => {
    expect(SEALED_CATEGORIES).toEqual(Object.keys(CATEGORY_LABELS_SERVER));
  });
  it('contains every category categorizeProduct can emit', () => {
    const emitted = ['booster_box', 'collector_booster_box', 'bundle', 'commander_deck', 'other'];
    for (const c of emitted) expect(SEALED_CATEGORIES).toContain(c);
    // categorizeProduct output is always a valid key
    expect(SEALED_CATEGORIES).toContain(categorizeProduct('Foundations Play Booster Box'));
  });
});
  • [ ] Step 2: Run the test to verify it fails

Run: npm test -- sealedProductUtils Expected: FAIL β€” SEALED_CATEGORIES is undefined.

  • [ ] Step 3: Add the export

In server/utils/sealedProductUtils.js, before module.exports:

js
// Ordered category vocabulary β€” the keys categorizeProduct() emits and the
// SealedProduct.category enum's labeled subset. Single source of truth for
// the buylist sealed categoryRates schema (server/schemas/buylistConfig.js)
// and the settings UI's per-category rows.
const SEALED_CATEGORIES = Object.keys(CATEGORY_LABELS_SERVER);

Update module.exports to include it:

js
module.exports = { categorizeProduct, CATEGORY_LABELS_SERVER, CATEGORY_TO_MSRP_TYPE, mapCategoryToMsrpType, SEALED_CATEGORIES };
  • [ ] Step 4: Run the test to verify it passes

Run: npm test -- sealedProductUtils Expected: PASS.

  • [ ] Step 5: Commit
bash
git add server/utils/sealedProductUtils.js server/utils/sealedProductUtils.test.js
git commit -m "Export sealed category vocabulary for buylist config"

Task 3: Sealed defaults + config response ​

Files:

  • Modify: server/services/buylistConfigService.js:18-32 (BASE_BUYLIST_DEFAULTS), :74-83 (buildBuylistConfigResponse)
  • Test: server/services/buylistConfigService.test.js

Interfaces:

  • Consumes: SEALED_CATEGORIES (Task 2), plugin.supportsSealed.

  • Produces: resolved config has a sealed block { enabled, useMsrpFallback, defaultRate, categoryRates }. buildBuylistConfigResponse returns sealedCategories: [{ id, name }] for supportsSealed games (else []).

  • [ ] Step 1: Write the failing tests

Append to server/services/buylistConfigService.test.js:

js
it('includes a sealed defaults block per game', () => {
  const cfg = svc.getGameBuylistConfig({ buylistConfig: {} }, 'mtg');
  expect(cfg.sealed).toEqual({
    enabled: false,
    useMsrpFallback: true,
    defaultRate: 0.60,
    categoryRates: {}
  });
});

it('response includes sealed categories for a supportsSealed game', () => {
  const resp = svc.buildBuylistConfigResponse({ buylistConfig: {} }, 'mtg');
  expect(resp.sealedCategories.length).toBeGreaterThan(0);
  expect(resp.sealedCategories[0]).toHaveProperty('id');
  expect(resp.sealedCategories[0]).toHaveProperty('name');
});

(Use the file's existing import alias for the service β€” shown here as svc.)

  • [ ] Step 2: Run to verify it fails

Run: npm test -- buylistConfigService Expected: FAIL β€” cfg.sealed is undefined.

  • [ ] Step 3: Implement

In server/services/buylistConfigService.js, add to BASE_BUYLIST_DEFAULTS (after instantQuote):

js
    // Sealed-product buylist. Separate from the singles rates above: sealed
    // has no rarity/finish/condition. defaultRate + categoryRates is the whole
    // model; store-credit bonus is reused from storeCreditBonus above.
    sealed: {
        enabled: false,
        useMsrpFallback: true,   // pass-through to the sealed price basis (MSRP fallback)
        defaultRate: 0.60,       // pay 60% of market basis
        categoryRates: {}        // per-category overrides, keys = SEALED_CATEGORIES
    }

At the top of the file add:

js
const { SEALED_CATEGORIES, CATEGORY_LABELS_SERVER } = require('../utils/sealedProductUtils');

In buildBuylistConfigResponse, add sealedCategories to the returned object:

js
function buildBuylistConfigResponse(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 })),
        sealedCategories: plugin.supportsSealed
            ? SEALED_CATEGORIES.map((id) => ({ id, name: CATEGORY_LABELS_SERVER[id] }))
            : [],
        config: getGameBuylistConfig(store, gameId)
    };
}
  • [ ] Step 4: Run to verify it passes

Run: npm test -- buylistConfigService Expected: PASS.

  • [ ] Step 5: Commit
bash
git add server/services/buylistConfigService.js server/services/buylistConfigService.test.js
git commit -m "Add sealed buylist config defaults and category vocabulary"

Task 4: Sealed config validation schema ​

Files:

  • Modify: server/schemas/buylistConfig.js:16-53 (buildBuylistConfigUpdateSchema)
  • Test: server/schemas/schemas.test.js (co-located schema tests) or server/schemas/buylistConfig.test.js (create)

Interfaces:

  • Consumes: SEALED_CATEGORIES (Task 2), plugin (for existing rarity keys).

  • Produces: the update schema accepts a strict sealed object; categoryRates keys are limited to SEALED_CATEGORIES, each a rateSchema.nullable().optional().

  • [ ] Step 1: Write the failing tests

Create server/schemas/buylistConfig.test.js:

js
import { describe, it, expect } from 'vitest';
import pkg from './buylistConfig.js';
const { buildBuylistConfigUpdateSchema } = pkg;
import plugins from '../plugins/index.js';

const mtg = plugins.getPlugin('mtg');

describe('sealed config validation', () => {
  it('accepts a valid sealed block', () => {
    const r = buildBuylistConfigUpdateSchema(mtg).safeParse({
      sealed: { enabled: true, defaultRate: 0.6, categoryRates: { booster_box: 0.7 } }
    });
    expect(r.success).toBe(true);
  });
  it('rejects an unknown category key', () => {
    const r = buildBuylistConfigUpdateSchema(mtg).safeParse({
      sealed: { categoryRates: { not_a_category: 0.5 } }
    });
    expect(r.success).toBe(false);
  });
  it('rejects a rate above 1', () => {
    const r = buildBuylistConfigUpdateSchema(mtg).safeParse({ sealed: { defaultRate: 1.5 } });
    expect(r.success).toBe(false);
  });
  it('allows null to clear a category override', () => {
    const r = buildBuylistConfigUpdateSchema(mtg).safeParse({
      sealed: { categoryRates: { booster_box: null } }
    });
    expect(r.success).toBe(true);
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm test -- buylistConfig Expected: FAIL β€” sealed key rejected by the outer .strict().

  • [ ] Step 3: Implement

In server/schemas/buylistConfig.js, add the import at top:

js
const { SEALED_CATEGORIES } = require('../utils/sealedProductUtils');

Inside buildBuylistConfigUpdateSchema, before the closing .strict() of the returned object, add a sealed property:

js
        sealed: z.object({
            enabled: z.boolean().optional(),
            useMsrpFallback: z.boolean().optional(),
            defaultRate: rateSchema.optional(),
            // null clears a stored category override (see applyGameBuylistConfigUpdate).
            categoryRates: z.object(Object.fromEntries(
                SEALED_CATEGORIES.map((c) => [c, rateSchema.nullable().optional()])
            )).strict().optional()
        }).strict().optional(),
  • [ ] Step 4: Run to verify it passes

Run: npm test -- buylistConfig Expected: PASS.

  • [ ] Step 5: Extend the null-clear stripping to sealed categoryRates

applyGameBuylistConfigUpdate (buylistConfigService.js) currently strips null from rarityRates only. Add the same for sealed.categoryRates. After the existing rarityRates strip loop:

js
    if (merged.sealed?.categoryRates) {
        for (const [cat, rate] of Object.entries(merged.sealed.categoryRates)) {
            if (rate === null) delete merged.sealed.categoryRates[cat];
        }
    }

Add a test to buylistConfigService.test.js:

js
it('strips a null sealed categoryRate on update', () => {
  const store = { buylistConfig: { mtg: { sealed: { categoryRates: { booster_box: 0.7 } } } }, markModified() {} };
  const merged = svc.applyGameBuylistConfigUpdate(store, 'mtg', { sealed: { categoryRates: { booster_box: null } } });
  expect(merged.sealed.categoryRates.booster_box).toBeUndefined();
});

Run: npm test -- buylistConfig buylistConfigService Expected: PASS.

  • [ ] Step 6: Commit
bash
git add server/schemas/buylistConfig.js server/schemas/buylistConfig.test.js server/services/buylistConfigService.js server/services/buylistConfigService.test.js
git commit -m "Validate sealed buylist config updates against category vocabulary"

Task 5: Quote engine β€” sealed pricing ​

Files:

  • Modify: server/services/buylistQuoteService.js (add functions; branch priceSelectedLines; extend getDeps)
  • Test: server/services/buylistQuoteService.test.js

Interfaces:

  • Consumes: plugin.findSealedProducts({ uuids }), plugin.preloadSealedPrices([{ uuid, setCode, category }]) β†’ Map<uuid,{price,source}>, categorizeProduct(name), per-game config config.sealed.

  • Produces:

    • applySealedBuylistRate({ basisPrice, category, quantity }, sealedConfig) β†’ { offerPrice, ruleApplied, lineTotal }.
    • findSealedByUuid(sealedUuid, game, deps) β†’ { sealedUuid, name, setCode, category } | null.
    • resolveSealedBasisPrice({ game, sealedUuid, setCode, category, useMsrpFallback }, deps) β†’ number | null.
    • priceIdentifiedSealedLine(selected, game, buylistConfig, deps) β†’ BuylistOrder line shape with lineType:'sealed'.
    • priceSelectedLines routes lineType === 'sealed' lines through the sealed path.
  • [ ] Step 1: Write the failing unit tests

Append to server/services/buylistQuoteService.test.js. Use the file's existing _setDeps/_resetDeps pattern:

js
describe('sealed pricing', () => {
  const sealedConfig = { enabled: true, useMsrpFallback: true, defaultRate: 0.6, categoryRates: { booster_box: 0.7 } };

  afterEach(() => svc._resetDeps());

  it('applySealedBuylistRate uses categoryRates when set', () => {
    const r = svc.applySealedBuylistRate({ basisPrice: 100, category: 'booster_box', quantity: 2 }, sealedConfig);
    expect(r.offerPrice).toBe(70);
    expect(r.ruleApplied).toBe('sealed:category:booster_box');
    expect(r.lineTotal).toBe(140);
  });

  it('applySealedBuylistRate falls back to defaultRate for null/unknown category', () => {
    const r = svc.applySealedBuylistRate({ basisPrice: 100, category: null, quantity: 1 }, sealedConfig);
    expect(r.offerPrice).toBe(60);
    expect(r.ruleApplied).toBe('sealed:default');
  });

  it('applySealedBuylistRate returns 0 when basis is null', () => {
    const r = svc.applySealedBuylistRate({ basisPrice: null, category: 'bundle', quantity: 3 }, sealedConfig);
    expect(r).toEqual({ offerPrice: 0, ruleApplied: 'no-price', lineTotal: 0 });
  });

  it('priceIdentifiedSealedLine composes a matched sealed line', async () => {
    svc._setDeps({
      getPlugin: () => ({}),
      findSealedByUuid: async () => ({ sealedUuid: 'u1', name: 'FDN Booster Box', setCode: 'FDN', category: 'booster_box' }),
      resolveSealedBasisPrice: async () => 100
    });
    // deps default to getDeps(), which returns the _setDeps-injected object above.
    const line = await svc.priceIdentifiedSealedLine({ sealedUuid: 'u1', quantity: 2 }, 'mtg', { sealed: sealedConfig });
    expect(line.matchStatus).toBe('matched');
    expect(line.lineType).toBe('sealed');
    expect(line.game).toBe('mtg');
    expect(line.sealedUuid).toBe('u1');
    expect(line.category).toBe('booster_box');
    expect(line.offerPrice).toBe(70);
    expect(line.included).toBe(true);
  });

  it('priceIdentifiedSealedLine returns unmatched when uuid not found', async () => {
    svc._setDeps({ findSealedByUuid: async () => null });
    const line = await svc.priceIdentifiedSealedLine({ sealedUuid: 'missing', quantity: 1 }, 'mtg', { sealed: sealedConfig });
    expect(line.matchStatus).toBe('unmatched');
    expect(line.included).toBe(false);
  });

  it('priceSelectedLines routes sealed lines to the sealed path', async () => {
    svc._setDeps({
      getGameBuylistConfig: () => ({ sealed: sealedConfig, priceBasis: 'market' }),
      priceIdentifiedLine: async () => ({ matchStatus: 'matched', lineType: 'single', game: 'mtg', offerPrice: 1, quantity: 1, included: true }),
      priceIdentifiedSealedLine: async (sel) => ({ matchStatus: 'matched', lineType: 'sealed', game: sel.game, sealedUuid: sel.sealedUuid, offerPrice: 70, quantity: sel.quantity, included: true })
    });
    const { lines } = await svc.priceSelectedLines({
      store: {},
      lines: [
        { lineType: 'single', game: 'mtg', cardUuid: 'c1', finish: '', condition: 'nm', quantity: 1 },
        { lineType: 'sealed', game: 'mtg', sealedUuid: 'u1', quantity: 2 }
      ]
    });
    expect(lines.find((l) => l.lineType === 'sealed').offerPrice).toBe(70);
    expect(lines.find((l) => l.lineType === 'single')).toBeTruthy();
  });
});

Note: priceIdentifiedSealedLine and priceSelectedLines read collaborators from getDeps(); the tests above inject them via _setDeps. Match the exact deps-access style already used by priceIdentifiedLine in this file (it calls deps.findCardByUuid etc.).

  • [ ] Step 2: Run to verify it fails

Run: npm test -- buylistQuoteService Expected: FAIL β€” applySealedBuylistRate and the sealed functions are undefined.

  • [ ] Step 3: Implement the sealed functions

In server/services/buylistQuoteService.js, add near applyBuylistRate:

js
/**
 * Sealed buylist rate math. Sealed has no rarity/finish/condition β€” the whole
 * model is categoryRates[category] ?? defaultRate, times the market basis.
 * category is null for games with no sealed taxonomy (Pokemon/Riftbound),
 * which falls through to defaultRate.
 */
function applySealedBuylistRate({ basisPrice, category, quantity }, sealedConfig) {
    if (basisPrice == null) {
        return { offerPrice: 0, ruleApplied: 'no-price', lineTotal: 0 };
    }
    const override = category != null ? sealedConfig.categoryRates?.[category] : undefined;
    const rate = override ?? sealedConfig.defaultRate;
    const ruleApplied = override != null ? `sealed:category:${category}` : 'sealed:default';
    const offerPrice = round2(basisPrice * rate);
    return { offerPrice, ruleApplied, lineTotal: round2(offerPrice * quantity) };
}

sealedConfig.categoryRates?.[category] indexes a plain config object by a server-derived category string. If security/detect-object-injection flags it, add the same targeted eslint-disable-next-line used elsewhere in this file and note the value is a re-derived category from a fixed vocabulary.

Add the identity + basis resolvers near findCardByUuid / resolveBasisPrice:

js
/**
 * Reverse lookup for a sealed line: identity comes ONLY from the source
 * catalog, never from client-echoed fields (rule 7). category is derived the
 * same way sealed pricing already derives it β€” categorizeProduct(name) β€” so
 * the rate lookup and the MSRP fallback agree. Pokemon/Riftbound have no
 * category taxonomy, so category stays null there.
 */
async function findSealedByUuid(sealedUuid, game, deps = getDeps()) {
    const plugin = deps.getPlugin(game);
    const results = await plugin.findSealedProducts({ uuids: [sealedUuid] });
    if (!results || results.length === 0) return null;
    const { product, setData } = results[0];
    const category = plugin.supportsSealed && game === 'mtg'
        ? deps.categorizeProduct(product.name)
        : null;
    return {
        sealedUuid,
        name: product.name,
        setCode: setData.code,
        category
    };
}

/**
 * Sealed market basis via the plugin's own price data (MTG: Price collection +
 * MSRP fallback; Pokemon/Riftbound: their price collections). Plugin-driven so
 * this never hard-codes an MTG price path.
 */
async function resolveSealedBasisPrice({ game, sealedUuid, setCode, category, useMsrpFallback }, deps = getDeps()) {
    const plugin = deps.getPlugin(game);
    const priceMap = await plugin.preloadSealedPrices(
        [{ uuid: sealedUuid, setCode, category }],
        { useMsrpFallback }
    );
    const result = priceMap.get(sealedUuid);
    const price = result?.price;
    return typeof price === 'number' && price > 0 ? price : null;
}

/**
 * Sealed twin of priceIdentifiedLine. Selector-only; sealedUuid is the sole
 * trusted input, everything else is re-derived.
 */
async function priceIdentifiedSealedLine(selected, game, buylistConfig, deps = getDeps()) {
    const sealed = await deps.findSealedByUuid(selected.sealedUuid, game, deps);
    if (!sealed) {
        return {
            rawLine: `${selected.quantity} [unavailable sealed ${selected.sealedUuid}]`,
            matchStatus: 'unmatched',
            lineType: 'sealed',
            game,
            sealedUuid: selected.sealedUuid,
            quantity: selected.quantity,
            included: false
        };
    }
    const basisPrice = await deps.resolveSealedBasisPrice(
        { game, sealedUuid: sealed.sealedUuid, setCode: sealed.setCode, category: sealed.category, useMsrpFallback: buylistConfig.sealed.useMsrpFallback },
        deps
    );
    const { offerPrice, ruleApplied } = deps.applySealedBuylistRate(
        { basisPrice, category: sealed.category, quantity: selected.quantity },
        buylistConfig.sealed
    );
    return {
        rawLine: `${selected.quantity} ${sealed.name}${sealed.setCode ? ` [${sealed.setCode}]` : ''}`,
        matchStatus: 'matched',
        lineType: 'sealed',
        game,
        sealedUuid: sealed.sealedUuid,
        cardName: sealed.name,
        setCode: sealed.setCode,
        category: sealed.category,
        quantity: selected.quantity,
        basisPrice,
        ruleApplied,
        offerPrice,
        included: true
    };
}

Branch priceSelectedLines β€” change the batch mapper to route by lineType:

js
        const batchResults = await Promise.all(
            batch.map((selected) => selected.lineType === 'sealed'
                ? deps.priceIdentifiedSealedLine(selected, selected.game, getConfig(selected.game), deps)
                : deps.priceIdentifiedLine(selected, selected.game, getConfig(selected.game), deps))
        );

Add the new functions and categorizeProduct to getDeps() defaults and to module.exports:

js
// in getDeps() return object:
        categorizeProduct: require('../utils/sealedProductUtils').categorizeProduct,
        findSealedByUuid,
        resolveSealedBasisPrice,
        applySealedBuylistRate,
        priceIdentifiedSealedLine,
js
// in module.exports:
    applySealedBuylistRate,
    findSealedByUuid,
    resolveSealedBasisPrice,
    priceIdentifiedSealedLine,
  • [ ] Step 4: Run to verify it passes

Run: npm test -- buylistQuoteService Expected: PASS.

  • [ ] Step 5: Verify preloadSealedPrices accepts the options arg (parity check)

The MTG plugin's preloadSealedPrices(products) (server/plugins/mtg/index.js:644) ignores a second arg today and always calls getSealedProductPrice(uuid, setCode, category) β€” whose default useMsrpFallback is true. Confirm Pokemon (server/plugins/pokemon/index.js:580) and Riftbound implementations also tolerate a second arg (extra args are harmless in JS). If you want useMsrpFallback:false to actually flow through, thread options.useMsrpFallback into each plugin's getSealedProductPrice/equivalent call. Minimum for this task: leave the plugins as-is (MSRP fallback stays on) and record in the commit body that useMsrpFallback:false is not yet honored end-to-end β€” a documented, not silent, limitation. Run the game-parity skill and name mtg/pokemon/riftbound.

  • [ ] Step 6: Commit
bash
git add server/services/buylistQuoteService.js server/services/buylistQuoteService.test.js
git commit -m "Price sealed products in the buylist quote engine"

Task 6: selectedCardLineSchema becomes single|sealed union ​

Files:

  • Modify: server/schemas/buylistOrder.js:12-20
  • Test: server/schemas/buylistOrder.test.js (create) β€” covers both merchant and public reuse.

Interfaces:

  • Produces: selectedCardLineSchema accepts either a single line (cardUuid required; lineType absent or 'single') or a sealed line (lineType:'sealed', sealedUuid required, no finish/condition). Both require game + quantity.

  • [ ] Step 1: Write the failing tests

Create server/schemas/buylistOrder.test.js:

js
import { describe, it, expect } from 'vitest';
import pkg from './buylistOrder.js';
const { selectedCardLineSchema } = pkg;

describe('selectedCardLineSchema union', () => {
  it('accepts a single line without lineType (back-compat)', () => {
    expect(selectedCardLineSchema.safeParse({ cardUuid: 'c1', game: 'mtg', finish: '', condition: 'nm', quantity: 1 }).success).toBe(true);
  });
  it('accepts a sealed line', () => {
    expect(selectedCardLineSchema.safeParse({ lineType: 'sealed', sealedUuid: 'u1', game: 'mtg', quantity: 2 }).success).toBe(true);
  });
  it('rejects a sealed line missing sealedUuid', () => {
    expect(selectedCardLineSchema.safeParse({ lineType: 'sealed', game: 'mtg', quantity: 1 }).success).toBe(false);
  });
  it('rejects a sealed line carrying card fields (strict)', () => {
    expect(selectedCardLineSchema.safeParse({ lineType: 'sealed', sealedUuid: 'u1', cardUuid: 'c1', game: 'mtg', quantity: 1 }).success).toBe(false);
  });
  it('rejects a single line missing cardUuid', () => {
    expect(selectedCardLineSchema.safeParse({ game: 'mtg', finish: '', condition: 'nm', quantity: 1 }).success).toBe(false);
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm test -- buylistOrder Expected: FAIL β€” sealed line rejected (no sealedUuid/lineType support yet).

  • [ ] Step 3: Implement the union

Replace the selectedCardLineSchema definition in server/schemas/buylistOrder.js:

js
// A search-and-select single line. lineType absent => 'single' (back-compat
// with clients that predate sealed support).
const singleSelectedLineSchema = z.object({
    lineType: z.literal('single').optional(),
    cardUuid: z.string().min(1, 'cardUuid is required'),
    game: z.string().min(1, 'game is required'),
    // '' means "no explicit finish choice" β€” the server re-derives it.
    finish: z.string(),
    condition: z.enum(VALID_CONDITIONS),
    quantity: z.number().int().min(1).max(9999)
}).strict();

// A sealed-product line. Only sealedUuid/game/quantity are trusted; identity,
// category, and price are re-derived server-side (rule 7). No finish/condition.
const sealedSelectedLineSchema = z.object({
    lineType: z.literal('sealed'),
    sealedUuid: z.string().min(1, 'sealedUuid is required'),
    game: z.string().min(1, 'game is required'),
    quantity: z.number().int().min(1).max(9999)
}).strict();

// Sealed tried first (it requires the discriminator); anything else is a
// single. z.union rather than discriminatedUnion because a legacy single line
// may omit lineType entirely.
const selectedCardLineSchema = z.union([sealedSelectedLineSchema, singleSelectedLineSchema]);

Keep the existing module.exports list (still exports selectedCardLineSchema).

  • [ ] Step 4: Run to verify it passes

Run: npm test -- buylistOrder Expected: PASS. Also run npm test -- publicBuylist (the public schema imports this) to confirm no regression.

  • [ ] Step 5: Commit
bash
git add server/schemas/buylistOrder.js server/schemas/buylistOrder.test.js
git commit -m "Accept sealed lines in the buylist order line schema"

Task 7: Sealed-enablement gate in resolveLineGameConfigs ​

Files:

  • Modify: server/services/buylistConfigService.js:103-123 (resolveLineGameConfigs)
  • Test: server/services/buylistConfigService.test.js

Interfaces:

  • Consumes: lines each with game and optional lineType; per-game config with sealed.enabled.

  • Produces: resolveLineGameConfigs returns { error, status } naming any game that has a sealed line while its sealed.enabled is false; otherwise { configsByGame } unchanged.

  • [ ] Step 1: Write the failing tests

Append to server/services/buylistConfigService.test.js:

js
it('rejects a sealed line whose game has sealed disabled', () => {
  const store = { buylistConfig: { mtg: { enabled: true, sealed: { enabled: false } } } };
  const r = svc.resolveLineGameConfigs(store, [{ game: 'mtg', lineType: 'sealed' }]);
  expect(r.error).toMatch(/sealed buylist is not enabled/i);
  expect(r.error).toMatch(/mtg/);
  expect(r.status).toBe(400);
});

it('allows a sealed line when sealed is enabled', () => {
  const store = { buylistConfig: { mtg: { enabled: true, sealed: { enabled: true } } } };
  const r = svc.resolveLineGameConfigs(store, [{ game: 'mtg', lineType: 'sealed' }]);
  expect(r.error).toBeUndefined();
  expect(r.configsByGame.mtg).toBeTruthy();
});

it('ignores sealed.enabled for single-only orders', () => {
  const store = { buylistConfig: { mtg: { enabled: true, sealed: { enabled: false } } } };
  const r = svc.resolveLineGameConfigs(store, [{ game: 'mtg', lineType: 'single' }]);
  expect(r.error).toBeUndefined();
});
  • [ ] Step 2: Run to verify it fails

Run: npm test -- buylistConfigService Expected: FAIL β€” sealed lines currently pass the gate.

  • [ ] Step 3: Implement

In resolveLineGameConfigs, after the existing disabled (singles-enabled) check block, before return { configsByGame }, add:

js
    const sealedGames = [...new Set(lines.filter((l) => l.lineType === 'sealed').map((l) => l.game))];
    const sealedDisabled = sealedGames.filter((g) => !configsByGame[g].sealed?.enabled);
    if (sealedDisabled.length) {
        return { error: `Sealed buylist is not enabled for: ${sealedDisabled.join(', ')}`, status: 400 };
    }
  • [ ] Step 4: Run to verify it passes

Run: npm test -- buylistConfigService Expected: PASS.

  • [ ] Step 5: Commit
bash
git add server/services/buylistConfigService.js server/services/buylistConfigService.test.js
git commit -m "Gate sealed buy lines on per-game sealed enablement"

Task 8: Route + public integration test ​

Files:

  • Test only: co-located buylist route test (follow the existing POST /buylist/orders test file/pattern) and server/services/publicBuylistService.test.js.
  • Modify (only if needed): server/services/publicBuylistService.js β€” ensure toPublicOrder surfaces lineType/category.

Interfaces:

  • Consumes: everything from Tasks 1–7. No new production interface unless toPublicOrder omits the new fields.

  • [ ] Step 1: Write the failing route tests

In the buylist route test file, add (mirror the existing create-order test setup β€” auth stub, req.store, and any _setDeps used to stub the quote engine):

js
it('creates a mixed single + sealed order', async () => {
  // store config: mtg.enabled = true, mtg.sealed.enabled = true
  const res = await request(app).post('/api/buylist/orders').send({
    customer: { email: 'seller@example.com' },
    lines: [
      { cardUuid: 'c1', game: 'mtg', finish: '', condition: 'nm', quantity: 1 },
      { lineType: 'sealed', sealedUuid: 'u1', game: 'mtg', quantity: 2 }
    ]
  });
  expect(res.status).toBe(201);
  const sealed = res.body.order.lines.find((l) => l.lineType === 'sealed');
  expect(sealed.sealedUuid).toBe('u1');
});

it('rejects a sealed line when sealed is disabled for the game', async () => {
  // store config: mtg.enabled = true, mtg.sealed.enabled = false
  const res = await request(app).post('/api/buylist/orders').send({
    customer: { email: 'seller@example.com' },
    lines: [{ lineType: 'sealed', sealedUuid: 'u1', game: 'mtg', quantity: 1 }]
  });
  expect(res.status).toBe(400);
  expect(res.body.error).toMatch(/sealed buylist is not enabled/i);
});

Stub the sealed catalog/price deps via buylistQuoteService._setDeps so findSealedByUuid/resolveSealedBasisPrice return deterministic values (no live DB), matching how the existing selector-line route test stubs findCardByUuid.

  • [ ] Step 2: Run to verify it fails

Run: npm test -- buylist (route + public) Expected: FAIL if toPublicOrder drops lineType/category, or PASS on the merchant route if Tasks 1–7 are wired (then this task only adds the public fix + coverage).

  • [ ] Step 3: Surface sealed fields in the public order shape

In server/services/publicBuylistService.js, find toPublicOrder (the line-mapping helper) and ensure each mapped line includes lineType and category (add them to the picked fields). Add a public-service test:

js
it('public order lines expose lineType and category', async () => {
  // build an order with a sealed line, call toPublicOrder / createPublicOrder
  // assert the returned line has lineType 'sealed' and the category string
});
  • [ ] Step 4: Run to verify it passes

Run: npm test -- buylist Expected: PASS.

  • [ ] Step 5: Commit
bash
git add server/routes/api.test.js server/services/publicBuylistService.js server/services/publicBuylistService.test.js
git commit -m "Cover mixed single and sealed buy orders end to end"

Task 9: getGamesInfo exposes supportsSealed ​

Files:

  • Modify: server/plugins/index.js:72-80 (getGamesInfo)
  • Test: server/plugins/index.test.js (create if absent) or the existing plugins registry test.

Interfaces:

  • Produces: each entry from getGamesInfo() and /api/games includes supportsSealed: boolean. Consumed by the client (Tasks 11–12).

  • [ ] Step 1: Write the failing test

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

describe('getGamesInfo', () => {
  it('includes supportsSealed for each game', () => {
    const games = plugins.getGamesInfo();
    const mtg = games.find((g) => g.id === 'mtg');
    expect(mtg).toHaveProperty('supportsSealed');
    expect(typeof mtg.supportsSealed).toBe('boolean');
  });
});
  • [ ] Step 2: Run to verify it fails

Run: npm test -- plugins Expected: FAIL β€” supportsSealed absent.

  • [ ] Step 3: Implement

In getGamesInfo(), add to the mapped object:

js
        supportsSealed: plugin.supportsSealed,
  • [ ] Step 4: Run to verify it passes

Run: npm test -- plugins Expected: PASS.

  • [ ] Step 5: Commit
bash
git add server/plugins/index.js server/plugins/index.test.js
git commit -m "Expose supportsSealed in games info for the client"

Task 10: Sealed search on /catalog/sealed ​

Files:

  • Modify: server/schemas/catalog.js (catalogSealedQuerySchema) β€” add optional search.
  • Modify: server/routes/api.js:1246-1450 (three branches of /catalog/sealed).
  • Test: co-located catalog route test.

Interfaces:

  • Produces: GET /api/catalog/sealed?game=<id>&search=<name> returns products whose name matches search (case-insensitive substring), each with { uuid, name, setCode, setName, category?, suggestedPrice, pricingSource }. Empty/rejected for !plugin.supportsSealed.

  • [ ] Step 1: Write the failing test

In the catalog route test file:

js
it('filters sealed products by name search', async () => {
  const res = await request(app).get('/api/catalog/sealed').query({ game: 'mtg', search: 'foundations', limit: 5 });
  expect(res.status).toBe(200);
  for (const p of res.body.sealedProducts) {
    expect(p.name.toLowerCase()).toContain('foundations');
  }
});

(If the test DB has no MTG sealed rows, assert instead that a search with no matches returns an empty sealedProducts array and status 200 β€” the point is the param is accepted and applied.)

  • [ ] Step 2: Run to verify it fails

Run: npm test -- catalog Expected: FAIL β€” search rejected by the query schema (unknown key) or ignored.

  • [ ] Step 3: Add search to the schema

In server/schemas/catalog.js, catalogSealedQuerySchema, add:

js
    search: trimmedSearchSchema,   // optional name filter (same schema the cards/sets searches use)
  • [ ] Step 4: Apply the filter in all three branches

In server/routes/api.js, /catalog/sealed handler:

Derive once near the top of the handler:

js
        const search = req.query.search ? String(req.query.search).trim().toLowerCase() : null;
  • Riftbound branch (aggregation): add a name regex to filter before the count/aggregate (escape the input):
js
            if (search) filter.name = { $regex: escapeRegex(search), $options: 'i' };
  • Pokemon branch (post-findSealedProducts filter, in-memory): filter allResults before paging:
js
            const filtered = search
                ? allResults.filter(({ product }) => product.name.toLowerCase().includes(search))
                : allResults;
            const total = filtered.length;
            const paged = filtered.slice(skip, skip + limit);
  • MTG branch (SetModel aggregation): add a $match on the unwound sealed product name after $unwind and reflect it in both the count pipeline and the main pipeline (keep $project before $sort, Β§5.6):
js
        if (search) {
            sealedMatchStage['data.sealedProduct.name'] = { $regex: escapeRegex(search), $options: 'i' };
        }

(The handler already pushes sealedMatchStage into both pipelines when non-empty β€” adding the name key reuses that path. Confirm escapeRegex is imported/available in api.js; if not, inline a small escape or reuse the existing helper.)

  • [ ] Step 5: Run to verify it passes

Run: npm test -- catalog Expected: PASS.

  • [ ] Step 6: Parity + commit

Run the game-parity skill (all three branches touched). Then:

bash
git add server/schemas/catalog.js server/routes/api.js server/routes/api.test.js
git commit -m "Add name search to the sealed catalog endpoint"

Task 11: SealedSearchPicker component ​

Files:

  • Create: client/src/pages/buylist/SealedSearchPicker.jsx
  • Test: client/src/pages/buylist/SealedSearchPicker.test.jsx

Interfaces:

  • Produces: <SealedSearchPicker game={game} onSelect={fn} apiClient={api} />. Debounced name search against /catalog/sealed?search=&game=&limit=8; on pick calls onSelect(product) where product has { uuid, name, setCode, setName, category, suggestedPrice }.

  • [ ] Step 1: Write the failing test

jsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import SealedSearchPicker from './SealedSearchPicker';

it('searches sealed products and selects one', async () => {
  const apiClient = { get: vi.fn().mockResolvedValue({ data: { sealedProducts: [
    { uuid: 'u1', name: 'Foundations Play Booster Box', setCode: 'FDN', setName: 'Foundations', category: 'booster_box', suggestedPrice: 100 }
  ] } }) };
  const onSelect = vi.fn();
  render(<SealedSearchPicker game="mtg" onSelect={onSelect} apiClient={apiClient} />);
  fireEvent.change(screen.getByLabelText(/search sealed/i), { target: { value: 'foundations' } });
  await waitFor(() => screen.getByText(/Foundations Play Booster Box/));
  fireEvent.click(screen.getByText(/Foundations Play Booster Box/));
  expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ uuid: 'u1', category: 'booster_box' }));
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- SealedSearchPicker Expected: FAIL β€” component does not exist.

  • [ ] Step 3: Implement (mirror CardSearchPicker.jsx)
jsx
import { useEffect, useRef, useState } from 'react';
import api from '../../utils/api';
import { Input, Text } from '../../components/retroui';

const SEARCH_DEBOUNCE_MS = 300;

export default function SealedSearchPicker({ game, onSelect, apiClient = api }) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [searching, setSearching] = useState(false);
  const debounceRef = useRef(null);

  useEffect(() => {
    if (!query.trim()) { setResults([]); return undefined; }
    if (debounceRef.current) clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(async () => {
      setSearching(true);
      try {
        const { data } = await apiClient.get('/catalog/sealed', { params: { search: query.trim(), game, limit: 8 } });
        setResults(data.sealedProducts || []);
      } catch {
        setResults([]);
      } finally {
        setSearching(false);
      }
    }, SEARCH_DEBOUNCE_MS);
    return () => clearTimeout(debounceRef.current);
  }, [query, game, apiClient]);

  const pick = (product) => { onSelect(product); setQuery(''); setResults([]); };

  return (
    <div className="relative">
      <label htmlFor="sealed-search" className="font-semibold text-sm block mb-1">Search sealed products to add</label>
      <Input
        id="sealed-search"
        aria-label="Search sealed products"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Start typing a product name…"
      />
      {searching && <Text className="text-xs text-muted-foreground mt-1">Searching…</Text>}
      {results.length > 0 && (
        <ul className="absolute z-10 mt-1 w-full max-w-md bg-card border-2 border-black rounded shadow-md">
          {results.map((p) => (
            <li key={p.uuid}>
              <button
                type="button"
                className="w-full text-left px-3 py-2 hover:bg-muted text-sm flex justify-between items-baseline gap-2"
                onClick={() => pick(p)}
              >
                <span>
                  <span className="font-semibold">{p.name}</span>
                  <span className="text-muted-foreground"> β€” {p.setName || p.setCode}</span>
                </span>
                {typeof p.suggestedPrice === 'number' && (
                  <span className="font-semibold whitespace-nowrap">${p.suggestedPrice.toFixed(2)}</span>
                )}
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- SealedSearchPicker Expected: PASS.

  • [ ] Step 5: Commit
bash
git add client/src/pages/buylist/SealedSearchPicker.jsx client/src/pages/buylist/SealedSearchPicker.test.jsx
git commit -m "Add sealed product search picker for buy orders"

Task 12: NewBuyInTab β€” Singles/Sealed toggle + sealed rows ​

Files:

  • Modify: client/src/pages/buylist/NewBuyInTab.jsx
  • Test: client/src/pages/buylist/NewBuyInTab.test.jsx

Interfaces:

  • Consumes: SealedSearchPicker (Task 11); games entries carry supportsSealed (Task 9).

  • Produces: sealed cart rows submit { lineType: 'sealed', sealedUuid, game, quantity }.

  • [ ] Step 1: Write the failing test

Add to NewBuyInTab.test.jsx (mock api so /games returns [{ id:'mtg', name:'MTG', supportsSealed:true }] and /catalog/sealed returns one product):

jsx
it('adds a sealed product and submits it as a sealed line', async () => {
  // ... render, wait for games, click the "Sealed" picker toggle
  // type in the sealed search, pick the product
  // assert a cart row shows a "Sealed" chip and NO finish/condition <select>
  // click Generate Offer
  // assert api.post was called with lines containing
  //   { lineType: 'sealed', sealedUuid: 'u1', game: 'mtg', quantity: 1 }
});

(Flesh out using the file's existing render/mock helpers β€” assert on api.post.mock.calls for the sealed line shape and that the sealed row has no Finish for / Condition for labeled control.)

  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- NewBuyInTab Expected: FAIL β€” no sealed picker/rows.

  • [ ] Step 3: Implement

Add a picker-type toggle and sealed handling:

jsx
// new state near the existing useState declarations:
const [pickerType, setPickerType] = useState('single'); // 'single' | 'sealed'
const activeGameSupportsSealed = games.find((g) => g.id === game)?.supportsSealed;

// reset to singles when switching to a game without sealed support:
useEffect(() => {
  if (!activeGameSupportsSealed && pickerType === 'sealed') setPickerType('single');
}, [activeGameSupportsSealed, pickerType]);

const addSealed = (product) => {
  setSelectedCards((prev) => {
    if (prev.some((c) => c.lineType === 'sealed' && c.sealedUuid === product.uuid)) return prev;
    return [...prev, {
      key: `sealed:${product.uuid}`,
      lineType: 'sealed',
      sealedUuid: product.uuid,
      game,
      name: product.name,
      setCode: product.setCode,
      category: product.category,
      quantity: 1
    }];
  });
};

Give single rows a stable key too (they currently key on c.uuid): when adding a single, set key: \single:${card.uuid}`, and change the table key={c.uuid}tokey={c.key}and dedup/remove logic to usec.key`. (This namespaces single vs sealed ids so a single and a sealed product with colliding ids can't clash β€” spec Β§Client.)

In mode === 'select', render the picker toggle + conditional picker:

jsx
{activeGameSupportsSealed && (
  <div className="flex gap-2">
    <Button variant={pickerType === 'single' ? 'default' : 'outline'} size="sm" onClick={() => setPickerType('single')}>Singles</Button>
    <Button variant={pickerType === 'sealed' ? 'default' : 'outline'} size="sm" onClick={() => setPickerType('sealed')}>Sealed</Button>
  </div>
)}
{pickerType === 'sealed'
  ? <SealedSearchPicker game={game} onSelect={addSealed} />
  : <CardSearchPicker game={game} onSelect={addCard} />}

In the cart row, branch sealed vs single. For a sealed row show a chip and dashes for finish/condition:

jsx
<td className="px-4 py-2">
  {c.name}
  {c.lineType === 'sealed' && (
    <span className="ml-2 inline-block px-1.5 py-0.5 border-2 border-black bg-primary text-xs font-medium rounded">Sealed</span>
  )}
</td>
{/* ... Game, Set columns unchanged; # column: c.number for singles, 'β€”' for sealed ... */}
{/* Finish + Condition cells: render the existing <select>s only for singles, 'β€”' for sealed */}

Update submit to send the right line shape per type:

jsx
lines: selectedCards.map((c) => c.lineType === 'sealed'
  ? { lineType: 'sealed', sealedUuid: c.sealedUuid, game: c.game, quantity: Number(c.quantity) }
  : { cardUuid: c.uuid, game: c.game, finish: c.finish, condition: c.condition, quantity: Number(c.quantity) })

Import the new picker at the top:

jsx
import SealedSearchPicker from './SealedSearchPicker';
  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- NewBuyInTab Expected: PASS.

  • [ ] Step 5: Commit
bash
git add client/src/pages/buylist/NewBuyInTab.jsx client/src/pages/buylist/NewBuyInTab.test.jsx
git commit -m "Let merchants add sealed products to a buy order"

Task 13: BuylistPortalPage β€” sealed in the customer portal ​

Files:

  • Modify: client/src/pages/portal/BuylistPortalPage.jsx
  • Test: client/src/pages/portal/BuylistPortalPage.test.jsx

Interfaces:

  • Same picker/toggle/row/submit changes as Task 12, applied to the portal's own cart state. Portal posts to /public/buylist/:shop/orders; the sealed line shape is identical.

  • [ ] Step 1: Write the failing test

Mirror Task 12's test against the portal component: adding a sealed product produces a { lineType:'sealed', sealedUuid, game, quantity } line in the submitted payload, and the sealed row hides finish/condition.

  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- BuylistPortalPage Expected: FAIL.

  • [ ] Step 3: Implement

Apply the same changes as Task 12 (picker-type toggle gated on the active game's supportsSealed, addSealed, namespaced row keys, sealed chip, finish/condition hidden for sealed, per-type submit mapping). Reuse SealedSearchPicker. The portal fetches games from the same /games (public) endpoint, which now carries supportsSealed (Task 9). If the portal uses a different price/label rendering, keep it β€” only the line-type plumbing is shared.

  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- BuylistPortalPage Expected: PASS.

  • [ ] Step 5: Commit
bash
git add client/src/pages/portal/BuylistPortalPage.jsx client/src/pages/portal/BuylistPortalPage.test.jsx
git commit -m "Let customers add sealed products in the buylist portal"

Task 14: Sealed chip in review + orders list ​

Files:

  • Modify: client/src/pages/buylist/BuylistReviewPage.jsx, client/src/pages/buylist/BuylistOrdersTab.jsx
  • Test: their co-located test files.

Interfaces:

  • Consumes: order lines carry lineType; the list/review render a "Sealed" chip when line.lineType === 'sealed'.

  • [ ] Step 1: Write the failing tests

For each component, render an order containing one single and one sealed line; assert a "Sealed" chip appears for the sealed line only (and, in the review page, that finish/condition are not shown for the sealed line).

  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- BuylistReviewPage BuylistOrdersTab Expected: FAIL.

  • [ ] Step 3: Implement

In each, where a line's card name is rendered, append the same chip used in Task 12 when line.lineType === 'sealed':

jsx
{line.lineType === 'sealed' && (
  <span className="ml-2 inline-block px-1.5 py-0.5 border-2 border-black bg-primary text-xs font-medium rounded">Sealed</span>
)}

In the review page, guard any finish/condition display with line.lineType !== 'sealed'.

  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- BuylistReviewPage BuylistOrdersTab Expected: PASS.

  • [ ] Step 5: Commit
bash
git add client/src/pages/buylist/BuylistReviewPage.jsx client/src/pages/buylist/BuylistOrdersTab.jsx client/src/pages/buylist/BuylistReviewPage.test.jsx client/src/pages/buylist/BuylistOrdersTab.test.jsx
git commit -m "Show a sealed chip on buy order review and list"

Task 15: BuylistSettingsTab β€” sealed rate section ​

Files:

  • Modify: client/src/pages/buylist/BuylistSettingsTab.jsx
  • Test: client/src/pages/buylist/BuylistSettingsTab.test.jsx

Interfaces:

  • Consumes: GET /api/buylist/config?game= now returns sealedCategories: [{id,name}] and config.sealed (Task 3). Saves via PUT /api/buylist/config?game= with a sealed partial (Task 4).

  • Produces: a sealed section (only when sealedCategories.length > 0) with an enable toggle, a default-rate input, and one rate input per category.

  • [ ] Step 1: Write the failing test

jsx
it('renders sealed rate rows and saves a sealed config update', async () => {
  // mock GET /buylist/config -> { rarities:[...], sealedCategories:[{id:'booster_box',name:'Booster Box'}], config:{ ..., sealed:{ enabled:false, defaultRate:0.6, categoryRates:{} } } }
  // render, toggle "Enable sealed buylist", set default rate to 0.65, set booster_box to 0.7, click Save
  // assert api.put called with a body whose sealed = { enabled:true, defaultRate:0.65, categoryRates:{ booster_box:0.7 } }
});
  • [ ] Step 2: Run to verify it fails

Run: npm run test:client -- BuylistSettingsTab Expected: FAIL β€” no sealed section.

  • [ ] Step 3: Implement

Mirror the existing rarity-rate section in BuylistSettingsTab.jsx (which already renders one input per rarities[] entry bound to config.rarityRates). Add a parallel sealed block bound to config.sealed:

  • A Switch (retroui) for sealed.enabled.
  • A number input for sealed.defaultRate.
  • One number input per sealedCategories entry, bound to sealed.categoryRates[id] (empty input β†’ omit / send null to clear, matching the rarity rows' clear behavior).
  • Render the whole block only when sealedCategories.length > 0.
  • On save, include sealed in the PUT body (rates as fractions 0–1, same convention as rarity rates).

Follow the file's existing state-management and save-handler patterns exactly β€” do not introduce a new pattern.

  • [ ] Step 4: Run to verify it passes

Run: npm run test:client -- BuylistSettingsTab Expected: PASS.

  • [ ] Step 5: Commit
bash
git add client/src/pages/buylist/BuylistSettingsTab.jsx client/src/pages/buylist/BuylistSettingsTab.test.jsx
git commit -m "Let merchants configure sealed buylist rates"

Task 16: Full verification pass ​

Files: none (verification only).

  • [ ] Step 1: Server tests + coverage

Run: npm test Expected: exit 0.

Run: npm run test:coverage Expected: every modified server file β‰₯70% on all four metrics. Add targeted tests if any modified file dips below.

  • [ ] Step 2: Client tests + build

Run: npm run test:client Expected: exit 0.

Run: npm run build Expected: exit 0.

  • [ ] Step 3: Lint

Run: npm run lint Expected: exit 0, no new warnings.

  • [ ] Step 4: Parity + literal audit

  • Run the game-parity skill; in the summary name mtg (mirrored), pokemon (exempt from per-category rates β€” no hasSealedCategoryTaxonomy), riftbound (same exemption as pokemon; sealed buylist itself is supported for all three games).

  • Confirm categoryRates keys trace to SEALED_CATEGORIES / CATEGORY_LABELS_SERVER (Β§5.4).

  • grep -n "|| 'mtg'\|?? 'mtg'\|= 'mtg'" across the diff β†’ zero identity defaults (Β§5.5).

  • Confirm the MTG /catalog/sealed search $match on name is placed after $unwind and before $sort (Β§5.6).

  • [ ] Step 5: Sync-affecting check

Not applicable β€” this feature performs no Shopify sync. No end-to-end sync bar required.


Self-Review Notes (author) ​

  • Spec coverage: data model β†’ T1; config defaults/response β†’ T3; config validation β†’ T4; quote engine β†’ T5; line schema union β†’ T6; sealed gate β†’ T7; route + public β†’ T8; supportsSealed exposure β†’ T9; search endpoint β†’ T10; picker β†’ T11; merchant UI β†’ T12; portal UI β†’ T13; review/list chips β†’ T14; settings UI β†’ T15; DoD β†’ T16. Every spec section maps to a task.
  • MSRP-fallback knob: useMsrpFallback:false is validated and stored but not yet threaded into the MTG plugin's getSealedProductPrice call (T5 Step 5 documents this as a known, non-silent limitation; fallback stays on). Flagged rather than hidden.
  • Category on non-MTG: intentionally null for Pokemon/Riftbound (no taxonomy) β†’ defaultRate. Asserted in T5.