Skip to content

Marketplace Sync β€” Design (Draft) ​

Date: 2026-07-30 Status: Approved (Brent, 2026-07-31 β€” decisions table below) Related: GitHub issue #316 Β· Notion "Marketplace Sync - Feature" (391e6bb4) Β· Notion "Pricing Plans" (392e6bb4)

Decisions Made (Brent, 2026-07-31) ​

DecisionChoice
v1 scopeTwo-way sync is the feature. One-way push may land as an intermediate PR, but the feature is not merchant-complete until marketplace sales decrement Shopify AND Shopify sales decrement marketplaces (PRs 1–4).
First marketplaceCardTrader (covers mtg, pokemon, riftbound). Manapool is marketplace #2, after two-way lands.
PricingSame computed price as Shopify. Per-marketplace fee-offset modifiers are a future follow-up, not in this arc.
Quantity modelShared pool β€” full Shopify on-hand pushed to every connected marketplace; order-driven decrements narrow the oversell window. No allocation UI.
eBayParked. Not in this arc; no developer-account paperwork yet.
TCGPlayerNo partner outreach. Requirements noted; nothing pursued.
Listing selectionAll managed_by='lgs-forge' products for the games the merchant enables per connection. No per-set opt-in in v1.

Problem ​

Merchants using competing tools (BinderPOS, TCG Sync) can list their card inventory on third-party marketplaces from one place. LGS Forge syncs to Shopify only. The Pricing Plans doc already sells "Marketplace Sync" as a Growth ($59.99) and Established ($99.99) entitlement, and pricingPlans.js already declares the marketplaceSync feature flag (server/config/pricingPlans.js:33,50,69,88,108) β€” nothing consumes it. docs/guides/billing.md:15 publicly promises it "as it ships."

Research summary (verified 2026-07-30) ​

ManapoolCardTradereBayTCGPlayer
GamesMTG onlyMTG, Pokemon, Riftbound (+10 more)All (category 183454)MTG/Pokemon
AuthPaste email + mpat_ tokenPaste bearer token (JWT)Full OAuth (RuName redirect, 2h access / 18-mo refresh)Closed β€” partner/BYO-key only since late 2024
Card identityScryfall ID (+ condition/finish/language), or TCGplayer id/SKUBlueprint id; blueprints carry scryfall_id + tcg_player_idSeller-defined SKU + item aspectsn/a
Bulk writes2,000 items/requestAsync bulk jobs + poll25 records/call (bulkUpdatePriceQuantity)n/a
Orders outPoll (since cursor) + order_created webhook (HMAC)Poll + order.* webhooks (HMAC)Poll Fulfillment API (no reliable REST webhook)n/a
Rate limitsUndocumented β€” assume conservative200 req/10s; job polling 1/s5,000 calls/day default; Growth Check to raise (3–5 day review)n/a
SandboxNoneNoneYes (separate keyset)n/a
GotchasAPI "subject to change without notice"; custom_external_id is publicly visibleCardTrader Zero: decrement stock at hub_pending; expansion-id mapping table neededCondition descriptors (graded 2750 / ungraded 4000); Authenticity Guarantee β‰₯ $200 changes fulfillment; 18-mo re-consentPursue partnership separately

Full agent research reports: session transcripts, 2026-07-30. Marketplace fee context: Manapool 5% (cap $50/item) + 2.9%+$0.30; eBay ~13% + AG routing; CardTrader varies (Zero vs classic).

Codebase facts that shape the design ​

  • No local inventory tracking exists. Shopify is the sole stock source; catalog collections are global templates (inventoryQty is written as literal 0 by all three plugins). StoreProduct.js is dead scaffolding ("NOT CURRENTLY IN USE"). A per-store listing-state record is new work β€” this is the core new data model.
  • Credential template: notificationConfig.slackWebhookUrl (Store.js:298-331, routes/notifications.js) β€” encrypted at rest via crypto.js AES-256-GCM, masked reads, null-to-disconnect. Marketplace credentials clone this pattern.
  • Feature gating is ready: requireFeature('marketplaceSync') (middleware exists, wired to zero routes β€” this feature is its first consumer). Client has no upgrade_required 403 handling yet; that's new UI work.
  • Queue plumbing: createQueueModule() factory; register in worker.js; concurrency 1 for rate-limited consumers; Mongo doc _id doubles as BullMQ jobId; tokens read live off Store, never in jobData (newer convention, priceUpdateQueue.js:50-52).
  • orders/paid webhook (Shopifyβ†’us) exists but discards line-item quantities and variant ids, is unpaginated (first: 50), and early-returns for stores without billing subscriptions β€” all must change if it drives marketplace decrements.
  • Outbound-call rule: analogous to rule 6 β€” each marketplace gets exactly one client module; nothing else calls its API. Rate limiter: mirror shopifyRateLimiter's structure (per-tenant instance, proactive wait, reactive backoff) but request-per-window semantics, keyed (shop, marketplace).

Approaches considered ​

A. Marketplace adapter layer (recommended). Mirror the game-plugin pattern that already works in this repo: server/marketplaces/BaseMarketplaceAdapter.js + one directory per marketplace, a single marketplace-sync queue/processor that is adapter-driven (no if (marketplace === ...) in the core), one new listing-state collection, credentials as an encrypted per-store sub-doc. Ships marketplace #1 slightly slower, but marketplace #2 and #3 are additive β€” and we already know we want β‰₯3. Prevents a Β§5.8-style duplication cycle.

B. Single-integration first, extract abstraction later. Build one marketplace as a plain service, refactor into adapters when the second lands. Fastest to first demo, but the repo has already paid for this lesson once with game plugins, and the issue explicitly asks for the integration surface to be designed first. Rejected.

C. Feed/CSV middleman. Export files merchants upload to marketplace tools. Not the product being sold; no automation, no inventory safety. Rejected.

Design (Approach A) ​

Phasing ​

  • PR 1 β€” Connections + registry. server/marketplaces/ registry + BaseMarketplaceAdapter; Store.marketplaceConnections sub-doc (encrypted creds, per-marketplace enable flags, per-game enable flags); Zod schemas; GET/PUT /api/marketplace-connections gated by requireFeature('marketplaceSync'); validate-on-save (Manapool/CardTrader GET /info-style call); Settings UI card (masked creds, connect/disconnect, upgrade upsell state for the 403).
  • PR 2 β€” Listing push (CardTrader). New marketplace_listings collection; marketplace-sync queue + processor; push pipeline: managed variants β†’ identity mapping β†’ price/qty β†’ bulk upsert β†’ record listing state. Manual "push now" + repeatable schedule (clone priceUpdateScheduler pattern). Intermediate state only β€” the feature is not merchant-complete until PRs 3–4 land (decision 2026-07-31).
  • PR 3 β€” Marketplace sales β†’ Shopify decrement. Webhook receivers (CardTrader order.*, Manapool order_created, HMAC-verified raw-body, same discipline as Shopify webhooks) + polling backstop; decrement Shopify via existing addInventoryQuantity (negative delta); idempotent marketplace_orders record; CardTrader Zero hub_pending treated as sold.
  • PR 4 β€” Shopify sales β†’ marketplace decrement. Extend orders/paid processing to capture variant ids + quantities (fix pagination, decouple from billing-subscription early-return), enqueue decrement jobs per connected marketplace.
  • PR 5+ β€” Manapool (marketplace #2). After two-way lands for CardTrader; the adapter layer absorbs it (MTG-only via supportedGames()).
  • eBay: parked β€” out of this arc entirely (decision 2026-07-31). When picked up it is its own arc: app registration, OAuth + RuName, sandbox, category aspects via Taxonomy API, condition descriptors, Growth Check, 18-month re-consent surfacing, AG β‰₯$200 warnings.
  • TCGPlayer: requirements-only; no partner outreach (decision 2026-07-31).

1. Data model ​

Store.marketplaceConnections (sub-doc, field-by-field per Β§5.2, round-trip test required):

js
marketplaceConnections: {
  manapool: {
    enabled: Boolean,
    emailEnc: String,        // encrypted (Manapool auth header pair)
    accessTokenEnc: String,  // encrypted `mpat_...`
    connectedAt: Date,
    enabledGames: [String],  // subset of enabledCatalogs; manapool validator: ['mtg'] only
    webhookSecretEnc: String,
    lastPushAt: Date, lastPushStatus: String,
  },
  cardtrader: {
    enabled: Boolean,
    accessTokenEnc: String,  // encrypted bearer JWT
    sharedSecretEnc: String, // webhook HMAC key (from GET /info)
    connectedAt: Date,
    enabledGames: [String],
    lastPushAt: Date, lastPushStatus: String,
  },
  // ebay: added in its PR β€” refresh token (enc), token expiry, re-consent due date, policies
}

marketplace_listings (new per-store collection β€” the listing-state join):

js
{
  shop: String,               // tenant key
  marketplace: String,        // 'manapool' | 'cardtrader' | 'ebay'
  game: String,               // explicit, never defaulted (Β§5.5)
  shopifyProductId: String, shopifyVariantId: String, sku: String,
  externalId: String,         // marketplace listing/product id
  externalIdentity: Object,   // e.g. { scryfallId, finish, condition, language } or { blueprintId }
  lastPushedPriceCents: Number, lastPushedQuantity: Number,
  status: String,             // 'active' | 'error' | 'delisted'
  lastError: String, lastSyncedAt: Date,
}
// indexes: {shop, marketplace, shopifyVariantId} unique; {shop, marketplace, status}

marketplace_orders (PR 3 β€” idempotency, mirrors ProcessedOrder): { shop, marketplace, externalOrderId, state, lineItems: [{externalId, sku, quantity}], shopifyAdjustedAt }, unique {shop, marketplace, externalOrderId}.

2. Adapter interface (server/marketplaces/BaseMarketplaceAdapter.js) ​

Sync core calls only these; game- and marketplace-specifics live in adapters:

  • validateConnection(creds) β†’ identity/ok
  • supportedGames() β†’ e.g. ['mtg'] for Manapool
  • mapVariant(variantDoc, game) β†’ external identity or null (unmappable β†’ recorded, not silently dropped)
  • pushListings(batch) β†’ per-item results (handles marketplace's own bulk semantics/job polling)
  • delist(listing)
  • fetchOrders(since) / verifyWebhook(rawBody, headers) / parseOrderEvent(payload)
  • rateLimiterConfig() β†’ window/limits for the shared MarketplaceRateLimiter

Identity mapping per game (vocabulary cited from API docs, Β§5.4 β€” re-verify against live responses at build time):

  • MTG β†’ Manapool: Scryfall ID + condition_id ∈ {NM,LP,MP,HP,DMG}, finish_id ∈ {NF,FO,EF}, language_id.
  • MTG β†’ CardTrader: blueprint via scryfall_id; condition strings "Near Mint"…"Poor"; mtg_foil bool. One-time expansion-codeβ†’expansion_id map.
  • Pokemon β†’ CardTrader: blueprint via tcg_player_id = TCGCSV productId (note: internal identity remains setID-number; productId is only the CardTrader join key).
  • Riftbound β†’ CardTrader: blueprint export per expansion; join key TBD at build time (likely tcg_player_id too).

3. Sync pipeline (PR 2) ​

Repeatable BullMQ job per store (clone of price-sync scheduling) + manual trigger:

  1. Load connected marketplaces Γ— enabled games; load managed_by='lgs-forge' variants for those games.
  2. Read current quantities from Shopify (bulk inventoryLevel query through shopifyAPI.js) β€” Shopify stays the stock source of truth; we push its numbers.
  3. Price from the existing per-game pricing pipeline output (same price as Shopify today; per-marketplace modifiers are an open question).
  4. Diff against marketplace_listings (only push changed price/qty β€” respects rate budgets), batch per adapter, record results.
  5. Job progress/results in the existing sync_jobs shape for UI polling.

4. Failure handling ​

  • Marketplace 401 β†’ flip connection to error, surface in Settings, never retry-loop (mirror needsReauth discipline).
  • No sandboxes (Manapool/CardTrader) β†’ adapter-level dryRun mode that logs intended writes; test against Brent's real seller accounts with cheap listings before GA.
  • Manapool API instability warning β†’ tolerant response parsing, contract tests against the saved OpenAPI spec.
  • Oversell window (one-way phase) β†’ explicit UI copy: "quantities sync on schedule; marketplace sales do not yet reduce Shopify stock" until PR 3 lands.

5. Testing ​

  • _setDeps injection for every adapter and the processor; co-located Vitest; β‰₯70% per-file (read the table β€” the gate is dead under Vitest 4).
  • Β§5.2 round-trip test for marketplaceConnections and every marketplace_listings field.
  • Β§5.1 game parity: mtg (Manapool+CardTrader), pokemon (CardTrader; Manapool exempt β€” MTG-only marketplace), riftbound (CardTrader; Manapool exempt). Every adapter PR states this.
  • Webhook receivers: HMAC verification tests with real signature fixtures.

Open Items Deliberately Left Behind ​

  • Per-marketplace price modifiers (fee offsets) β€” future config on the connection sub-doc.
  • eBay adapter β€” parked; research retained in this spec's table for when it's picked up.
  • TCGPlayer β€” closed API; revisit only if a partner path appears.
  • Per-set listing selection β€” v1 lists all managed products per enabled game; add selection UI only if merchants ask.
  • Riftbound β†’ CardTrader blueprint join key β€” verify tcg_player_id presence on Riftbound blueprints with one real API call during PR 2 (Β§5.4 discipline).
  • Manapool rate limits β€” undocumented; ask Manapool support before its adapter ships; until then the limiter uses a conservative default.
  • Pokemon marketplace leads evaluated and declined (2026-07-31, from Jonathan's Slack suggestions**)πŸ˜—* Rare Candy β€” no public seller API and no raw singles (graded/sealed only, spreadsheet-mediated onboarding); watch later for an API + raw-singles support. Alt (alt.xyz) β€” vault-custody model (cards must ship to Alt's vault to list) is structurally incompatible with syncing shelf inventory; ruled out. GCI-DB β€” not a marketplace; a product-data/UPC database possibly relevant to the separate sealed-barcodes effort, unverified coverage.