Appearance
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 inriftbound_products(productType: 'sealed').plugin.findSealedProducts({ setCodes, uuids })reads it. It is not the per-storeSealedProductinventory 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 useString(tcgplayerProductId). The same key is already used bygetSealedPriceTrendsandgetSealedProductPrice, 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-levelgamefield that design introduced is already present onBuylistOrder.buylistLineSchema(game: { required: true, index: true }) with the{ shop, 'lines.game' }index, and the create route already groups lines by game viaresolveLineGameConfigs. 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
sealedbuylist 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
rawTextsingle-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 (noSEALED_CATEGORIESvocabulary for their sealed products), so their sealed lines price atsealed.defaultRateonly. This is narrower thansupportsSealed, 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, notgame/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
}categoryRatesis keyed by theSealedProduct.categoryenum (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 whycomputeOrderTotalsneeds no change: a sealed line already carriesgame, and the totals function looks the bonus up per line by game. - No
conditionMultipliers, nobulk, nostopGapfor sealed โ omitted deliberately (a sealed box has one grade; bulk/stopGap are per-card micro-rules).defaultRate+categoryRatesis the whole sealed rate model. priceBasisis 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.useMsrpFallbackis 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 viaplugin.findSealedProducts({ uuids: [sealedUuid] }), returning re-derived identity (name,setCode,category,releaseDate). Client-echoed name/category are never trusted โ onlysealedUuidis treated as intent (ยง rule 7), same discipline asfindCardByUuid.resolveSealedBasisPrice({ game, sealedUuid, setCode, category, useMsrpFallback }, deps)โ resolves the market basis viaplugin.preloadSealedPrices([{ uuid, setCode, category }])(plugin-driven, so Pokemon/Riftbound resolve from their own price data, not the MTG-specificgetSealedProductPrice). Returns a number ornull.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 carryinglineType: '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/conditionBoth 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 existingCardSearchPicker; Sealed โ a newSealedSearchPicker. SealedSearchPicker(new component): per-game name search against the extended/catalog/sealed?search=endpoint; on select callsaddSealed.addSealed(product)pushes a cart row{ lineType: 'sealed', sealedUuid: product.uuid, game, name, setCode, category, quantity: 1 }. Dedup key for sealed rows issealedUuid(singles keepuuid); the two id spaces are namespaced bylineTypeso 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
lineTypeon 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.
hasSealedCategoryTaxonomyis true, socategoryRatesoverrides apply. - Pokemon โ supported. This is the case the First Pass review specifically called out.
hasSealedCategoryTaxonomyis false (no category vocabulary for Pokemon sealed products), so sealed lines price atsealed.defaultRateonly โ 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
hasSealedCategoryTaxonomyis false, so sealed lines price atsealed.defaultRateonly.
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, elsedefaultRate, ร basis. - MSRP-fallback basis flows through when market price is missing/stale and
useMsrpFallbackis true. - no-price sealed line โ
offerPrice: 0,ruleApplied: 'no-price',includedhandling matches singles. priceSelectedLineswith a mix of single and sealed lines prices each by its own path and config.
- sealed offer =
- Config (
buylistConfigService.test.js):sealeddefaults present per game; partial update deep-merges;categoryRatesnull-clear works. - Route (
apibuylist 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
lineTypeand offer. /catalog/sealed?search=returns name matches for a supported game; unsupported/supportsSealed:falsegame rejected/empty.
- sealed order for a game with
- Public (
publicBuylistService.test.js): sealed line quotes through the shared engine;sealed.enabledgate enforced;toPublicOrderincludeslineType/category. - Client: sealed row hides finish/condition and submits
lineType; toggle appears only forsupportsSealedgames; sealed chip renders in cart, review, and orders list.
Definition of done (from CLAUDE.md ยง7) โ
npm test+npm run test:clientgreen; 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.
categoryRatesvocabulary citesSealedProduct.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").
