Skip to content

Sealed Product Support for the Buylist โ€” Design โ€‹

Date: 2026-07-23 Status: Approved, pending implementation plan Issue: #401

Problem โ€‹

The buylist quotes singles only. A customer selling a sealed booster box, bundle, or commander deck can't be quoted through the buy flow at all โ€” the quote engine (server/services/buylistQuoteService.js) resolves every line against the game's singles catalog (plugin.getProductModel()), prices via per-finish raw prices, and applies rarity/condition/bulk/stopGap rules that have no meaning for a factory-sealed product.

From the First Pass review: "Buylist should also reference sealed product โ€” this would be huge in Pokemon." Sealed is a large share of what shops buy in, especially for Pokemon.

The infrastructure to price sealed already exists and is unused by the buylist:

  • A global sealed source catalog โ€” MTG sealed lives in SetModel.data.sealedProduct (MTGJSON); Riftbound sealed in riftbound_products (productType: 'sealed'). plugin.findSealedProducts({ setCodes, uuids }) reads it. It is not the per-store SealedProduct inventory collection, so a customer can sell a box the shop doesn't stock.
  • A sealed price basis โ€” plugin.preloadSealedPrices(products) returns a per-uuid market price. The MTG implementation (priceLookupService.getSealedProductPrice) returns TCGplayer retail with an MSRP fallback (SealedProductMSRP). Pokemon/Riftbound resolve from their own price collections via the same plugin method.
  • Consistent identity keys โ€” MTG uses the MTGJSON sealed uuid; Pokemon/Riftbound use String(tcgplayerProductId). The same key is already used by getSealedPriceTrends and getSealedProductPrice, so there is one identity per sealed product across pricing, trends, and catalog.

Decision โ€‹

Make sealed products first-class in the buylist: searchable and addable on both the merchant New Buy Order tab and the public customer portal, priced from the existing global sealed price basis, with a per-game, sealed-specific rate config. Sealed and single lines coexist in one order.

Relationship to adjacent features โ€‹

This spec is standalone โ€” it does not depend on either of the two other open buylist features it touches:

  • Cross-game buy orders (docs/superpowers/specs/2026-07-20-cross-game-buylist-orders-design.md). The line-level game field that design introduced is already present on BuylistOrder.buylistLineSchema (game: { required: true, index: true }) with the { shop, 'lines.game' } index, and the create route already groups lines by game via resolveLineGameConfigs. Sealed builds on that as-is; it adds only a line type discriminator, not a second game axis.
  • Tiered price ranges (#400). Sealed gets its own flat-rate-plus-category-overrides config here and does not wait for or reuse the tiered-range structure. If tiered ranges later ship, applying them to sealed is a separate, additive change.

Scope โ€‹

In scope:

  • Sealed line type on BuylistOrder.
  • Per-game sealed buylist config block.
  • Sealed pricing in the quote engine (selector path).
  • Sealed search + add on both the merchant tab (NewBuyInTab.jsx) and the public portal (BuylistPortalPage.jsx).
  • One new read endpoint: sealed search-by-name per game.

Out of scope:

  • Paste / CSV import of sealed. Sealed is selector-only. A ManaBox/ TCGplayer/plain-text list carries no reliable per-row "this is sealed" signal, and the paste path stays singles-only โ€” the same rationale the cross-game design used to keep rawText single-game.
  • Sealed condition grading. A sealed product is one grade (factory sealed). No condition multiplier. "Damaged box" pricing is explicitly deferred.
  • Tiered ranges for sealed (see above).
  • Per-category rate taxonomy for games without one. Gated by plugin.hasSealedCategoryTaxonomy โ€” Pokemon and Riftbound are exempt (no SEALED_CATEGORIES vocabulary for their sealed products), so their sealed lines price at sealed.defaultRate only. This is narrower than supportsSealed, which is true for all three games โ€” the sealed buylist itself (config, pricing, search) is not out of scope for Pokemon or Riftbound; see the game-parity note below.

Line-modeling approach โ€‹

Chosen: a discriminated line via a lineType field on the existing buylistLineSchema. A single order, cart, and list holds both single and sealed lines.

Alternatives rejected:

  • Separate sealedLines[] array on the order โ€” forks every consumer (computeOrderTotals, list badges, review page) into two code paths for no benefit; the line already has room for a discriminator.
  • Synthetic "card" with rarity: 'sealed' โ€” bends card vocabulary (finish, condition, rarity) onto a product that has none of it, the exact "Invented Vocabulary" trap (CLAUDE.md ยง5.4).

The discriminated approach costs almost nothing downstream because computeOrderTotals already only reads game, offerPrice, quantity, included, and matchStatus off each line โ€” all of which a sealed line supplies โ€” so order totals, store-credit blending, and market-total math work on sealed lines with no change.

Data model โ€” server/models/BuylistOrder.js โ€‹

buylistLineSchema gains three fields, declared field-by-field (CLAUDE.md ยง9, strict-mode drops undeclared sub-doc fields):

  • lineType: { type: String, enum: ['single', 'sealed'], default: 'single' } โ€” the default is correct back-compat: every line written before this change is a single. This is not a ยง5.5 identity default (it's a type tag, not game/shop), and it is only ever 'single' for legacy rows, which is what they are.
  • sealedUuid: String โ€” the sealed identity key (MTGJSON uuid / String( tcgplayerProductId)). Set on sealed lines only.
  • category: String โ€” the sealed product category (e.g. booster_box, bundle, commander_deck), re-derived server-side from the source catalog. Set on sealed lines only.

Sealed lines reuse the existing cardName (product name) and setCode, and leave cardUuid, finish, collectorNumber, and condition unset. No index change โ€” sealed lines are found via the existing { shop, 'lines.game' } index and the order _id.

Migration: none. Existing lines are all singles; the lineType default covers them without a backfill.

Round-trip test (CLAUDE.md ยง5.2): write a sealed line via the model, read it back, assert lineType, sealedUuid, and category survive โ€” a test that fails if any of the three schema lines is deleted.

Config โ€” server/services/buylistConfigService.js โ€‹

Add a sealed block to BASE_BUYLIST_DEFAULTS (per-game, Store.buylistConfig is Mixed keyed by game id โ€” same pattern as the rest of the buylist config):

js
sealed: {
    enabled: false,          // sealed buylist off until a merchant opts in
    useMsrpFallback: true,   // pass through to the sealed price basis
    defaultRate: 0.60,       // pay 60% of the basis price
    categoryRates: {}        // per-category overrides, fraction of basis
}
  • categoryRates is keyed by the SealedProduct.category enum (server/models/SealedProduct.js): booster_box, collector_booster_box, bundle, draft_booster, set_booster, collector_booster, jumpstart_booster, commander_deck, pioneer_deck, planeswalker_deck, theme_booster, prerelease_pack, two_player_starter, deck_builder_toolkit, beginner_box, starter_collection, gift_bundle, scene_box, from_the_vault, secret_lair, other. This is the literal vocabulary the source catalog transforms produce (ยง5.4 โ€” copied from the model enum, not invented). The client's per-category rows render from this same enum so keys always match.
  • Store-credit bonus reuses the game's existing storeCreditBonus โ€” one bonus rate per game, applied to singles and sealed alike. This is why computeOrderTotals needs no change: a sealed line already carries game, and the totals function looks the bonus up per line by game.
  • No conditionMultipliers, no bulk, no stopGap for sealed โ€” omitted deliberately (a sealed box has one grade; bulk/stopGap are per-card micro-rules). defaultRate + categoryRates is the whole sealed rate model.
  • priceBasis is not part of the sealed block: sealed has effectively one market source (retail) plus the MSRP fallback, so there is no market / lowest / lower_of choice to make. useMsrpFallback is the only basis knob.

applyGameBuylistConfigUpdate already deep-merges partial updates, so the sealed block is patchable the same way rarityRates is, including the null-clear convention for removing a categoryRates override.

buildBuylistConfigResponse gains the sealed category vocabulary the settings UI needs to render per-category rate rows (the enum list above), alongside the existing rarity metadata โ€” surfaced only for plugin.supportsSealed games.

Quote engine โ€” server/services/buylistQuoteService.js โ€‹

Sealed pricing is selector-only and mirrors the existing priceIdentifiedLine structure:

  • findSealedByUuid(sealedUuid, game, deps) โ€” reverse lookup via plugin.findSealedProducts({ uuids: [sealedUuid] }), returning re-derived identity (name, setCode, category, releaseDate). Client-echoed name/category are never trusted โ€” only sealedUuid is treated as intent (ยง rule 7), same discipline as findCardByUuid.
  • resolveSealedBasisPrice({ game, sealedUuid, setCode, category, useMsrpFallback }, deps) โ€” resolves the market basis via plugin.preloadSealedPrices([{ uuid, setCode, category }]) (plugin-driven, so Pokemon/Riftbound resolve from their own price data, not the MTG-specific getSealedProductPrice). Returns a number or null.
  • applySealedBuylistRate({ basisPrice, category, quantity }, sealedConfig) โ€” rate = sealedConfig.categoryRates[category] ?? sealedConfig.defaultRate; offerPrice = round2(basisPrice * rate); ruleApplied = 'sealed:category:<category>' or 'sealed:default'; basisPrice == null โ†’ { offerPrice: 0, ruleApplied: 'no-price', lineTotal: 0 }, exactly like the singles path.
  • priceIdentifiedSealedLine(selected, game, sealedConfig, deps) โ€” composes the three above into a matched line shape carrying lineType: 'sealed', game, sealedUuid, cardName, setCode, category, quantity, basisPrice, ruleApplied, offerPrice, included: true. On unmatched (uuid not in the source catalog): matchStatus: 'unmatched', included: false, mirroring the singles unmatched shape.

priceSelectedLines branches per line on selected.lineType: 'sealed' โ†’ priceIdentifiedSealedLine (resolving that game's sealed config once and caching, alongside the existing per-game singles config cache); anything else โ†’ the existing priceIdentifiedLine. Batching (GENERATE_QUOTE_BATCH_SIZE) is unchanged.

computeOrderTotals is unchanged โ€” sealed lines satisfy every field it reads.

API โ€” server/routes/api.js, server/schemas/buylistOrder.js โ€‹

Line schema (selectedCardLineSchema) โ€‹

Becomes a discriminated union on lineType, defaulting to 'single' so existing clients that send no lineType keep working:

js
// single line: cardUuid required (unchanged from today)
// sealed line: sealedUuid required, no finish/condition

Both variants carry game (required, validated isGameSupported) and quantity. For sealed, only sealedUuid, game, and quantity are trusted; category/name/setCode are re-derived server-side (ยง rule 7). Enforcement lives in the Zod schema + route (ยง rule 7 / ยง5.12); the client mirrors it as UX only.

POST /api/buylist/orders โ€‹

The route already resolves per-game config and rejects disabled games via resolveLineGameConfigs. Extend it so that when a sealed line of a given game is present, that game's sealed.enabled is also required โ€” one 400 that names every game whose sealed buylist is off (no silent partial acceptance, matching the existing disabled-game behavior). Single-only orders are unaffected.

resolveLineGameConfigs returns the sealed sub-config per game alongside the singles config so the engine has both without a second resolution pass.

Sealed search endpoint (new read surface) โ€‹

The picker needs name search, which /api/catalog/sealed (set/category browse) doesn't offer. Extend /api/catalog/sealed with an optional search (name) query param โ€” reusing its existing per-game, plugin.supportsSealed-gated handler and the global source catalog it already reads โ€” rather than adding a parallel route. The endpoint returns each product's identity (uuid, name, setCode, category) plus its market price for display; the buy offer itself is always recomputed server-side at order time.

Public portal โ€‹

server/schemas/publicBuylist.js reuses selectedCardLineSchema, so the discriminated union flows to the portal automatically. publicBuylistService's quote/create paths call the same priceSelectedLines, so sealed pricing is shared โ€” no portal-specific pricing logic. The portal's sealed-enablement check uses the same per-game sealed.enabled gate. toPublicOrder surfaces lineType/category so the portal review can render the sealed chip.

Client โ€‹

NewBuyInTab.jsx (merchant) and BuylistPortalPage.jsx (portal) โ€‹

  • A Singles / Sealed toggle in the picker area (shown only for the active game when plugin.supportsSealed). Singles โ†’ the existing CardSearchPicker; Sealed โ†’ a new SealedSearchPicker.
  • SealedSearchPicker (new component): per-game name search against the extended /catalog/sealed?search= endpoint; on select calls addSealed.
  • addSealed(product) pushes a cart row { lineType: 'sealed', sealedUuid: product.uuid, game, name, setCode, category, quantity: 1 }. Dedup key for sealed rows is sealedUuid (singles keep uuid); the two id spaces are namespaced by lineType so a single and a sealed product can't collide.
  • Cart rows for sealed show a "Sealed" chip next to the existing game badge and hide the finish/condition controls (neither applies). Submit sends lineType on every line.

BuylistReviewPage.jsx and BuylistOrdersTab.jsx โ€‹

Render the same "Sealed" chip (computed from line.lineType), so a merchant or customer sees a mixed order's composition at a glance.

Games & parity โ€‹

Sealed buylist is gated on plugin.supportsSealed, which is true for MTG, Pokemon, and Riftbound โ€” all three get sealed config, pricing, and search:

  • MTG โ€” supported, including per-category rates. hasSealedCategoryTaxonomy is true, so categoryRates overrides apply.
  • Pokemon โ€” supported. This is the case the First Pass review specifically called out. hasSealedCategoryTaxonomy is false (no category vocabulary for Pokemon sealed products), so sealed lines price at sealed.defaultRate only โ€” the settings UI does not render per-category rate rows for this game.
  • Riftbound โ€” supported, same as Pokemon: sealed config/pricing/search are active, but hasSealedCategoryTaxonomy is false, so sealed lines price at sealed.defaultRate only.

The game-parity skill (ยง5.1) runs before commit: any per-game field (sealed config block, categoryRates vocabulary, the preloadSealedPrices call) is checked across server/plugins/, the per-game models, and the importers, and the summary names mtg (mirrored) / pokemon (exempt from per-category rates โ€” no hasSealedCategoryTaxonomy) / riftbound (same exemption as pokemon).

Testing โ€‹

  • Schema round-trip (ยง5.2): sealed line persists lineType, sealedUuid, category.
  • Quote engine (buylistQuoteService.test.js):
    • sealed offer = categoryRates[category] when set, else defaultRate, ร— basis.
    • MSRP-fallback basis flows through when market price is missing/stale and useMsrpFallback is true.
    • no-price sealed line โ†’ offerPrice: 0, ruleApplied: 'no-price', included handling matches singles.
    • priceSelectedLines with a mix of single and sealed lines prices each by its own path and config.
  • Config (buylistConfigService.test.js): sealed defaults present per game; partial update deep-merges; categoryRates null-clear works.
  • Route (api buylist tests):
    • sealed order for a game with sealed.enabled: false โ†’ 400 naming the game; no order created.
    • mixed single + sealed order succeeds; each line carries the right lineType and offer.
    • /catalog/sealed?search= returns name matches for a supported game; unsupported/supportsSealed:false game rejected/empty.
  • Public (publicBuylistService.test.js): sealed line quotes through the shared engine; sealed.enabled gate enforced; toPublicOrder includes lineType/category.
  • Client: sealed row hides finish/condition and submits lineType; toggle appears only for supportsSealed games; sealed chip renders in cart, review, and orders list.

Definition of done (from CLAUDE.md ยง7) โ€‹

  • npm test + npm run test:client green; modified files โ‰ฅ70% coverage.
  • New endpoint param passes through validate(); sealed config + line changes covered by the tests above.
  • Per-game parity checklist complete; mtg/pokemon/riftbound each named.
  • New persisted fields declared in schema + round-trip test.
  • categoryRates vocabulary cites SealedProduct.category (ยง5.4).
  • No || 'mtg'-style identity defaults introduced.
  • Commit message states the merchant-visible outcome (e.g. "Let merchants quote sealed products on buy orders").