Skip to content

TCGplayer Inventory Import — Design

Date: 2026-08-04 Status: Approved (Brent, 2026-08-04 — decisions table below) Source: Notion "TCG Import - Feature" (393e6bb4), under "Features that sell the product"

⚠️ Measured results supersede this spec's match-rate claims (2026-08-06)

PR 1 was built and then run against the real 8,514-row export. The spec's central claim — "309/319 sets resolve, ~24 rows unmatched, 0.8%" — was wrong. It was derived from checking that TCGplayer group ids existed in MTGJSON, never that they were unique, and never against the card-level join.

176 TCGplayer group ids map to more than one MTGJSON set. TCGplayer files a set and its promos in one group; MTGJSON splits them (10E "Tenth Edition" and P10E "Tenth Edition Promos" both carry tcgplayerGroupId: 1). The original one-to-one map was last-write-wins, and the promo set frequently won.

Measured on the reference export (2,944 stocked rows), each stage verified end-to-end:

StageMatchedUnmatchedProductsSets
As originally specified1,481 (50%)1,4631,391200
+ match across all sets in a group2,120 (72%)8242,013254
+ name-disambiguate colliding numbers, + prerelease fallback2,343 (80%)6012,235331
+ promo-pack fallback2,395 (81%)5492,282333

Plan against 81%, not 99%. The remaining 549 are not matcher bugs:

  • ~45 rows: catalog gaps. Art Series sets resolve correctly but we carry zero documents for them (AONE = 0 docs). Not recoverable by matching — a catalog-ingest question.
  • ~80 rows: genuine ambiguity across Mystery Booster 2, Unique & Miscellaneous Promos, WPN & Gateway, Promo Pack: Tarkir. Staying unmatched is correct.
  • 24 rows: the known unresolvable sets.
  • The rest is a long tail of ~19-row promo buckets. Each further fallback now creates ambiguity about as fast as it removes misses — the promo-pack fallback itself introduced 15 new ambiguous rows.

Known defects carried into PR 2 (adjudicated, not fixed in PR 1)

Both were found by the final review and deliberately deferred. Neither is load-bearing for PR 1 — it is preview-only, and the client renders from the POST response and never re-fetches the stored import.

1. Concurrent uploads from the same shop destroy each other. BLOCKS PR 2.buildPreview sweeps the shop's prior status: 'preview' imports before creating its own. Upload A runs ~20s; upload B's sweep matches A's still-in-flight document, deletes it and every line A has inserted. A's final findByIdAndUpdate then writes to a nonexistent doc — a silent no-op, no upsert — and A returns an importId that GET /tcg-import/:id will 404. Two browser tabs is enough; the race window is the whole request.

Harmless today only because nothing reads the stored import. The moment PR 2 makes it authoritative for product creation, this becomes data loss mid-import. Fix: move the sweep after create and exclude _id: { $ne: importDoc._id }, or exclude documents newer than request start.

2. The new catalog indexes do not build at deploy. API and worker connect with autoIndex: process.env.NODE_ENV !== 'production' (server/config/database.js:21, server/worker.js:201), so deploying this branch creates nothing. The indexes are built by transformSetsToProducts.js, which connects with a bare mongoose.connect and runs daily at 3:00 UTC via .github/workflows/update-mtg-sets.yml. Production therefore picks them up at the next nightly run, not at merge.

This interacts with the row cap. MAX_IMPORT_ROWS = 20,000 is defensible post-index: worst case 20,000 stocked rows is 6.8× the reference file's 2,944, ≈144s at the measured 21.2s, inside Cloud Run's --timeout 300. Pre-index that same worst case is ≈385s — over the timeout. Build the indexes out-of-band at deploy rather than relying on the nightly job.

Two further findings for PR 2:

  1. Runtime is 56.7s for a preview (19.2s before the fallbacks). That is a synchronous merchant-facing request. It needs backgrounding or caching before PR 2 builds on it.
  2. The catalog index does not cover this query. shopify_mtg_products_variants indexes {metafields.set_code, metafields.card_number}, but the matcher queries metafields.collector_number — a different field. Only the set_code prefix assists.

Re-verification recipe (Docker + local Mongo, npm run data:update-sets then npm run data:transform-variants-all): call buildPreview with real resolveSetCodes, real getPlugin and real models, stubbing only checkProductLimit, priceFns, calculateConditionPrice, getGamePricingConfig and Store. Anything that stubs resolveSetCodes will not catch this class of defect — that is exactly why seven task reviews missed it.

Problem

Merchants arriving from TCGplayer already have their singles inventory priced and quantified there. Today the only way to get it into Shopify through LGS Forge is to sync whole sets and hand-count stock — for a merchant with 4,082 units spread across 319 sets, that is not a real option. Competing tools (BinderPOS, TCG Sync) ingest a TCGplayer export directly.

The import is also the best onboarding funnel we have: a merchant uploads their export on day one, sees 2,829 products and a price comparison against their own listings, and upgrades because the tool showed its value before asking for money.

Decisions Made (Brent, 2026-08-04)

DecisionChoice
Import scopeOnly the listed cards. Card-level sync, not set-level. Syncing the 319 sets in full would create 100,000+ products — 20× the merchant's real inventory and past every tier cap.
Price opt-outNew price_locked flag honoured by priceUpdateService. A merchant who declines price syncing keeps the file's TCG Marketplace Price indefinitely, not just until the next daily run.
Inventory mathAbsolute set to Total Quantity. Re-import is idempotent. Accepted trade-off: a Shopify-only sale between exports is reverted by the next import.
ConditioningOnly the conditions present in the file (plus Near Mint, which the Condition option requires). The store-wide conditionVariants.enabled toggle is left untouched.
v1 scopeMTG singles + MTG sealed. Pokemon and Riftbound exempt — see Parity below.
Feature gateNone. Open to every store; only the existing product-count limit applies.
Tier estimateConservative estimate, worker corrects on demand. Preview reports "up to N new products"; if that trips the limit, a "check exactly" action runs the cache build as a background job and re-reports.
ArchitectureApproach A — import-owned pipeline reusing syncService internals via a new internal cardIdentities filter.

Source data — verified facts

Everything below was read from a real export, TCGplayer__Pricing_Custom_Export_20260804_020845.csv (Brent's store, 2026-08-04). Nothing here is assumed.

Header (verbatim):

TCGplayer Id, Product Line, Set Name, Product Name, Title, Number, Rarity, Condition,
TCG Market Price, TCG Direct Low, TCG Low Price With Shipping, TCG Low Price,
Total Quantity, Add to Quantity, TCG Marketplace Price, My Store Reserve Quantity,
My Store Price, Photo URL
PropertyMeasured value
Rows8,514 total · 2,944 with Total Quantity > 0 · 4,082 units
Distinct card identities (qty > 0)2,829 across 319 sets
Condition vocabularyNear Mint, Lightly Played, Moderately Played, Heavily Played, Damaged, each optionally suffixed Foil, plus Unopened
Non-NM share2,855 of 2,944 qty>0 rows. Conditioning is mandatory for this merchant, not incidental
Number blank196 of the qty>0 rows (Alliances, Anthologies, 9th Edition…)
RarityTCGplayer's own letters R/M/U/C/P/S/L/Tnot our vocabulary
Product Line"Magic" only in this file
Sealed4 rows with Condition = "Unopened" (Secret Lair, Ugin's Fate), all zero-quantity here
TitlePopulated on 8 rows (merchant's custom listing title); 0 of the qty>0 rows

TCGplayer Id is unusable as a join key

The column is SKU-level, not product-level. Measured: of 1,403 groups sharing (Set Name, Product Name, Number), 1,403 have a different id per condition and zero share one. Cephalid Constable (10th Edition #72) is 376116 LP / 344600 MP / 360358 HP / 328842 DMG.

The same holds for sealed: "Secret Lair Drop: Calling All Hydra Heads (WPN Exclusive) - Traditional Foil Edition" is TCGCSV productId 522559, but the export writes 7488026.

So the column joins to neither MTGJSON's identifiers.tcgplayerProductId nor TCGCSV's productId. All matching keys on Set Name + Product Name (+ Number).

The set bridge resolves 97% of sets

Measured against live TCGCSV (/tcgplayer/1/groups, 453 MTG groups) and MTGJSON SetList.json (868 sets, 818 carrying tcgplayerGroupId):

  • CSV Set Name → TCGCSV group name: 319 / 319 exact matches (case-insensitive).
  • TCGCSV groupId → MTGJSON tcgplayerGroupId → set code: 309 / 319 sets resolve.

The 10 that don't: AFR Ampersand Promos, Art Series: Lorwyn Eclipsed, Art Series: Marvel's Spider-Man, Art Series: Secrets of Strixhaven, Art Series: Teenage Mutant Ninja Turtles, Mystery Booster 2 Playtest Cards, Promo Pack: Ikoria, Promo Pack: Lorwyn Eclipsed, Promo Pack: Strixhaven, Special Occasion. Together they account for 24 of 2,944 rows / 25 units — 0.8%, which lands in the unmatched bucket with a plain reason rather than breaking the approach.

Codebase facts that shape the design

  • Sync has two lookup modes, neither fits. A normal set sync builds the managed-collection product cache once (syncProcessor.js:195) and matches in memory; a single-card sync skips it and does one live Shopify lookup per product (syncService.js:507). 319 set-jobs would rebuild the cache 319 times; 2,829 single-card jobs would fire 2,829 live lookups. The import needs one cache build shared across the whole file — no existing entry point provides that.
  • The per-card create+condition+stock operation already exists. POST /api/sync accepts cardNumber/condition/quantity/finishsyncService._applyCardInventoryShortcutshopifyAPI.ensureVariantForConditionAndInventory, which creates only the requested condition (plus Near Mint) and adds stock.
  • The sealed equivalent also exists. quickAddSealedProduct (routes/sealedProducts.js:303) creates a sealed product, sets its price, and adds quantity.
  • Inventory writes are delta-only. addInventoryQuantityinventoryAdjustQuantities (shopifyAPI.js:1483); absolute quantities are set only when activating a brand-new inventory item at a location. An absolute-set primitive is new work.
  • Price updates have no opt-out. priceUpdateService re-prices every variant whose managed_by metafield names a supported game (priceUpdateService.js:415), daily, for every store. There is no per-product or per-store price hold.
  • The TCGplayer condition parser already existsstripFoilSuffix / tcgplayerFinish / normalizeCondition (buylistQuoteService.js:342-359), verified against all 10 condition values in the real file.
  • Tier limits count products, not variants. checkProductLimit reads the managed collection's productsCount (planService.js:142). 2,829 products clears Growth (15,000) but exceeds Free (500) and Starter (2,000).
  • The upgrade-prompt pattern exists. 403 → upgrade_required → "View plans" link (MarketplaceSettings.jsx:131).

Approaches considered

A. Import-owned pipeline reusing sync internals (chosen). A dedicated import service and queue that builds the product cache once, groups matched lines by set code, and drives syncSetDirect through a new internal cardIdentities filter, then applies condition variants and absolute inventory per line. Reuses everything that knows how to build a card product — handle, SKU, metafields, collection membership, barcode, publication. Cost: threading a new option into syncSetDirect, a hot path shared by every existing sync.

B. Standalone processor calling shopifyAPI directly. Fastest to a demo. Rejected — it re-implements handle/SKU/metafield/collection/barcode generation, and §5.8 has already been paid for twice in this repo (the MTG treatment table, formatDuration). The first divergence would be silent.

C. Card-list sync as a public capability. Add cardIdentities to the public sync schema so any caller can sync an arbitrary card list. Genuinely reusable, and the right end state. Deferred — it designs a public surface for consumers that don't exist yet (§5.9), and the import still needs its own job to carry per-line price/quantity/condition directives that a generic card-list sync shouldn't own. Promote A's internal filter to public when a second consumer appears.

Design

Flow

1. Upload      POST /api/tcg-import/preview        (raw body, Content-Type: text/csv)
                 → parse → resolve sets → match → classify
                 → persist tcg_imports doc (status: 'preview') + tcg_import_lines
                 → return report

2. Review      matched / unmatched / zero-qty buckets, product-count estimate,
               tier verdict, 50-row price comparison, conditioning notice

3. Confirm     POST /api/tcg-import/:id/confirm    { priceMode }
                 → re-check tier → 403 upgrade_required if over
                 → enqueue job, status: 'queued'

4. Watch       GET  /api/tcg-import/:id            (progress polling, same shape
                                                    as GET /api/sync/:jobId)

   (optional)  POST /api/tcg-import/:id/exact-count
                 → background cache build → tierCheck.exact = true

All four routes are authenticated (dualModeAuth) and validated: a Zod schema per endpoint in server/schemas/tcgImport.js, wired through validate()tcgImportPreviewSchema (upload metadata), tcgImportConfirmSchema ({ priceMode }), and the shared params id schema. Server enforces; the client's file-type and size checks are UX mirrors that reference the server rule in a comment (rule 7).

The UI is a new page reached from the Sync area, not a Settings card — the import is an action a merchant takes, not a setting they configure. Cancellation is out of scope for v1; a running import is left to finish or fail, and re-uploading is the recovery path. No cancelled status is declared, so nothing ships unread (§5.9).

Worker phases:

PhaseWork
1Build managed-collection product cache once for the whole file
2Ensure set collections for the distinct set codes
3Per set code (checkpointed): syncSetDirect({ cardIdentities }), then per line the condition variant + absolute inventory
4Sealed lines → quickAddSealedProduct per uuid, with an absolute-quantity path (see below)
5Write summary, mark complete

Phase 3 checkpoints per set code, so a worker restart resumes at the next unfinished set — the same discipline syncProcessor's resumeFrom already uses.

Data model

tcg_imports — one doc per upload, per store:

js
{
  shop, game: 'mtg',              // explicit, never defaulted (§5.5)
  status: 'preview'|'queued'|'running'|'completed'|'failed',
  filename, uploadedAt, uploadedBy,
  priceMode: 'sync'|'locked',
  counts: { totalRows, zeroQty, matched, unmatched, sealed,
            distinctProducts, distinctSets, units },
  tierCheck: { tier, limit, current, estimated, allowed, requiredTier, exact: Boolean },
  setMap: [{ tcgSetName, groupId, setCode }],
  progress: { phase, setsDone: [String], productsCreated, productsUpdated,
              variantsCreated, inventorySet, failed },
  summary, error
}

tcg_import_lines — one doc per CSV row, indexed { importId, setCode }:

js
{
  importId, shop,
  raw: { tcgId, productLine, setName, productName, number, rarity, condition,
         totalQuantity, marketplacePrice, marketPrice },
  parsed: { finish, condition, quantity },
  match: { status: 'matched'|'unmatched'|'zero_qty'|'unsupported_line'|'sealed',
           reason, setCode, cardUuid, collectorNumber, rarity, finish },
  result: { productId, variantId, action, error }
}

Deliberately a separate collection rather than an array on the parent: 2,944 rows embed to roughly 600KB, but a 50k-row merchant would push a single doc toward Mongo's 16MB ceiling. §5.6's lesson is that materialising an unbounded set is a bug even when nothing errors today. The { importId, setCode } index lets the processor read one bounded set at a time and lets the unmatched report paginate.

Storing the raw row verbatim means the unmatched report can show the merchant their own line, and a re-run needs no re-upload.

price_locked — a new Shopify product metafield, declared in server/config/metafields.js beside managed_by and created by setupMetafieldDefinitions. priceUpdateService already selects managed_by per variant in its bulk query (priceUpdateService.js:246); adding a second metafield to that selection is nearly free, and the skip sits next to the existing unmanaged bucket at line 415.

Product-level, not variant-level: a locked product holds every one of its condition variants at the prices the import wrote. A real Shopify metafield rather than a Mongo flag, so the merchant can see and clear it in Shopify admin — unlocking needs no UI on day one.

Known wrinkle, surfaced in the preview: a card listed only as LP still gets a Near Mint variant, because that is how the Condition option works. NM receives our computed price at creation and is then locked. So a locked product can carry one price the merchant never chose.

Set bridge — fetched, not ingested

TCGCSV /tcgplayer/1/groups is 453 rows (~40KB) and matched all 319 set names exactly. Fetch at preview time with the repo's existing identifying User-Agent (lgs-forge/1.0 (ufkesba@gmail.com) — TCGCSV blocks unidentified agents), cache in Redis for 24h, then SetModel.find({ 'data.tcgplayerGroupId': { $in: [...] } }) for the codes. No new importer, no new global collection, no GitHub Action.

If TCGCSV is unreachable the preview fails with a clear message rather than falling back to matching MTGJSON set names — TCGplayer says "10th Edition" where MTGJSON says "Tenth Edition", so a guess would silently mismatch (§5.4).

Classification — first match wins, buckets exclusive

#TestBucketCount in the reference file
1Total Quantity ≤ 0zero_qty5,570
2Product Line"Magic"unsupported_line0
3Condition = "Unopened"sealed0 (4 rows, all zero-qty)
4otherwisesingle2,944

Order matters. Unopened must be intercepted before condition normalization: normalizeCondition('Unopened') falls through to 'nm', so a sealed booster box would silently import as a Near Mint single. That is the §5.4 shape exactly, and it gets a test that fails if the interception is removed.

Condition and finish — extract, don't copy

stripFoilSuffix, tcgplayerFinish, and normalizeCondition move to server/utils/tcgplayerCsv.js; buylistQuoteService imports them from there. Copying them would be the §5.8 shape. The existing /\s*foil\s*$/i suffix regex was checked against all 10 condition values in the real file, Damaged Foil included.

Identity chain

Set Name  →  TCGCSV group name  →  groupId  →  SetModel.data.tcgplayerGroupId  →  set code

                     ┌───────────────────────────────────────────────────────────────┘

   set_code + collector_number  ──exact──▶  matched
   set_code + card_name (i)     ──unique──▶ matched      (the 196 blank-Number rows)
   set_code + card_name (i)     ──multi───▶ unmatched: "3 cards named Forest in TMP"
   no set code / no hit          ─────────▶ unmatched: "we don't carry Art Series: …"

The name fallback is scoped to the resolved set. matchCatalogLine's existing fallback is a global findOne on card name with no set constraint (buylistQuoteService.js:483); for an import that would match an Alliances Lodestone Bauble to whichever copy Mongo returned first. Scoping it is the most important correctness difference between this matcher and the buylist one. An ambiguous name resolves to unmatched, never to an arbitrary pick.

Rarity comes from our catalog, never from the CSV. The file's R/M/U/C/P/S/L/T is TCGplayer's vocabulary; metafields.rarity is "Mythic"/"Rare". The CSV rarity is kept in raw for diagnostics and read by nothing (§5.4).

Finish resolution uses the plugin's existing selectVariantByFinish.

Sealed matching (PR 4)

Name → TCGCSV product row → productIdSealedProduct.identifiers.tcgplayerProductId. Demand-driven: ProductsAndPrices.csv is fetched only for groups that actually contain Unopened rows (2 sets in the reference file), so the 18,764-row Secret Lair payload is paid for only when needed.

The name side was verified against live TCGCSV. The SealedProduct.identifiers.tcgplayerProductId side is unverified — no live DB was available during design — so PR 4 opens by confirming real documents carry it. If they don't, the fallback is setCode + exact name against SealedProduct, and that changes PR 4's scope.

quickAddSealedProduct today calls addInventoryQuantity — a delta (sealedProducts.js:342). Left alone it would make sealed imports non-idempotent while singles are idempotent, contradicting the absolute-quantity decision. PR 4 therefore threads a quantity mode through it so the import path sets absolute while the existing catalog quick-add keeps its additive behaviour, which is what that UI means. Both behaviours get a test.

Preview warnings

  1. Pricing model. Our price is raw market × globalModifier.value × conditionMultiplier → minimum-price floor → rounding. Theirs is a hand-set absolute. The preview shows a side-by-side table of 50 sampled rows (25 highest-value + 25 random). Sampled, not exhaustive: pricing 2,829 cards means preloading snapshots for 319 sets inside an HTTP request, and §5.6 requires that read to stream via loadLatestSnapshotsBySlug — even streaming, that is minutes. The table is labelled a sample and claims no store-wide average, because an average we did not compute would be a fabricated number.
  2. Price opt-out. A choice between priceMode: 'sync' (our computed pricing, updated daily) and priceMode: 'locked' (the file's TCG Marketplace Price, held until the merchant clears the metafield), including the Near Mint wrinkle above.
  3. Conditioning. How many products gain condition variants, and that the store-wide toggle is not being changed.
  4. Tier. The verdict from checkProductLimit. If the conservative estimate trips the limit, the screen states plainly that already-synced cards are not yet excluded and offers a "check exactly" action that runs the cache build as a background job and re-reports with tierCheck.exact = true. Nobody upgrades for products they already own.

Execution

One new Shopify primitive: setInventoryQuantity, using inventorySetQuantities with ignoreCompareQuantity: true. Everything else — creates, variants, collections, barcodes, publications — routes through existing code. All Shopify traffic stays inside shopifyAPI.js behind shopifyRateLimiter (rule 6).

Known cost, accepted for v1: ensureVariantForConditionAndInventory opens with getProductWithVariantsGraphQL(productId) — one live fetch per card, so roughly 2,829 extra GraphQL calls for the reference file, on the order of 15–25 minutes of job wall time at Shopify's cost-aware limits. v1 ships it because it is correct and reuses the right code. The optimization — folding per-card condition directives into the batch buildVariantWritePayloads fan-out — touches the shared variant-write path and should be made against a measured baseline, not a guess.

Error handling

  • Per line: failures are recorded in result.error and the import continues. Three bad rows out of 2,944 yields 2,941 products, not a rollback.

  • Per job: resumable via progress.setsDone.

  • Upload: authenticated route (dualModeAuth), 20MB and 100,000 data rows, whichever trips first — roughly 15× the reference file (1.3MB / 8,514 rows), which leaves headroom for a large store without letting an arbitrary upload stream into Mongo. Over either cap is a 413 naming the limit. The buylist parser's MAX_LINES = 500 is a paste guard and must not be reused here.

    There is no multipart handler in this repo (no multer), and the global express.json() mounted at server/index.js:97 uses the 100kb default — a 1.3MB export would 413 before reaching any handler. So the preview route mounts its own body parser, express.text({ limit: '20mb', type: 'text/csv' }), scoped to that one route; the client sends the file contents as the raw request body with Content-Type: text/csv, reading it via FileReader.readAsText exactly as the buylist CSV upload already does (NewBuyInTab.jsx:117). The global JSON limit is not touched — raising it would widen the request surface of every other route.

  • Format detection is its own. detectCsvFormat keys on TCGplayer Id, present in both TCGplayer exports. The import requires Total Quantity and TCG Marketplace Price in the header — the markers unique to the Pricing Custom Export — and rejects anything else by naming the export the merchant should run instead.

  • No user-controlled outbound fetch. The TCGCSV URL is built from a groupId resolved through our own map, never from CSV text — the same boundary the buylist URL-import design drew.

Testing

LayerWhat
ClassificationEvery bucket, including Unopened-before-normalizeCondition; the test fails if the interception is removed
ParseAll 10 condition values from the real file, Damaged Foil included
MatchingCollector-number hit; set-scoped name fallback; ambiguous name → unmatched, never an arbitrary pick; unresolvable set
Schema§5.2 round-trip on tcg_imports, tcg_import_lines, and price_locked
Price lockpriceUpdateService skips a locked variant; the test fails if the skip is deleted
TierConservative estimate blocks; exact re-check unblocks a re-import
End-to-end§7's sync bar — a small testMode import, then assert the set's smart collection contains the products

Fixture: a ~50-row slice of the real export in server/services/__fixtures__/. Card and set names are public; the quantities are live business data. Default is to commit the real slice; Brent may instead ask for synthesized quantities preserving only the column and vocabulary shape.

Coverage: every modified file ≥70% on all four metrics, read from the per-file table by hand — the Vitest threshold gate is silently inoperative (§7).

Parity (§5.1)

v1 is MTG-only by decision.

PluginStatus
mtgImplemented — singles (PRs 1–3) and sealed (PR 4)
pokemonExempt — unverified. No real Pokemon export was available; the Product Line literal it writes is unknown, and inventing it is the §5.4 failure mode. Unblocked by one real export file.
riftboundExempt — unverified. Same reason.

The classifier routes any unrecognized Product Line to unsupported_line with a plain reason, rather than assuming MTG. No || 'mtg'-style identity default appears anywhere in the chain (§5.5).

Phasing

PRScope
1Parse + match + preview. tcgImportService, TCGCSV group map, both models, POST /api/tcg-import/preview, report UI. No Shopify writes — independently useful and independently testable against the real file.
2Execute singles. Queue + processor, cardIdentities threaded into syncSetDirect, setInventoryQuantity, condition variants, progress polling.
3Price lock. price_locked metafield + definition, honoured by priceUpdateService, the merchant's opt-out choice, and unlock documented in docs/guides/ as "clear the price_locked metafield in Shopify admin" — no unlock UI in this arc.
4Sealed. Unopened rows matched to SealedProduct, driven through quickAddSealedProduct with an absolute-quantity mode. Opens by verifying identifiers.tcgplayerProductId on real documents.

PR 1 de-risks everything else: it reports the real match rate on real merchant data before a single product is written.

Out of scope

  • Pokemon and Riftbound imports — pending real export files.
  • The TCGplayer Collection export (as opposed to the Pricing Custom Export). Rejected with a message naming the right export.
  • Two-way sync. This is a one-directional import. Shopify sales do not write back to TCGplayer; that is the marketplace-sync arc (2026-07-30-marketplace-sync-design.md), where TCGplayer is explicitly parked.
  • Fixing the buylist TCGplayer path's unscoped name fallback. Same latent weakness, different code path, its own ticket.
  • Batch-folding the condition fan-out to remove the per-card product fetch — deferred until measured.