Appearance
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) β
| Decision | Choice |
|---|---|
| v1 scope | Two-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 marketplace | CardTrader (covers mtg, pokemon, riftbound). Manapool is marketplace #2, after two-way lands. |
| Pricing | Same computed price as Shopify. Per-marketplace fee-offset modifiers are a future follow-up, not in this arc. |
| Quantity model | Shared pool β full Shopify on-hand pushed to every connected marketplace; order-driven decrements narrow the oversell window. No allocation UI. |
| eBay | Parked. Not in this arc; no developer-account paperwork yet. |
| TCGPlayer | No partner outreach. Requirements noted; nothing pursued. |
| Listing selection | All 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) β
| Manapool | CardTrader | eBay | TCGPlayer | |
|---|---|---|---|---|
| Games | MTG only | MTG, Pokemon, Riftbound (+10 more) | All (category 183454) | MTG/Pokemon |
| Auth | Paste email + mpat_ token | Paste bearer token (JWT) | Full OAuth (RuName redirect, 2h access / 18-mo refresh) | Closed β partner/BYO-key only since late 2024 |
| Card identity | Scryfall ID (+ condition/finish/language), or TCGplayer id/SKU | Blueprint id; blueprints carry scryfall_id + tcg_player_id | Seller-defined SKU + item aspects | n/a |
| Bulk writes | 2,000 items/request | Async bulk jobs + poll | 25 records/call (bulkUpdatePriceQuantity) | n/a |
| Orders out | Poll (since cursor) + order_created webhook (HMAC) | Poll + order.* webhooks (HMAC) | Poll Fulfillment API (no reliable REST webhook) | n/a |
| Rate limits | Undocumented β assume conservative | 200 req/10s; job polling 1/s | 5,000 calls/day default; Growth Check to raise (3β5 day review) | n/a |
| Sandbox | None | None | Yes (separate keyset) | n/a |
| Gotchas | API "subject to change without notice"; custom_external_id is publicly visible | CardTrader Zero: decrement stock at hub_pending; expansion-id mapping table needed | Condition descriptors (graded 2750 / ungraded 4000); Authenticity Guarantee β₯ $200 changes fulfillment; 18-mo re-consent | Pursue 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 (
inventoryQtyis written as literal 0 by all three plugins).StoreProduct.jsis 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 viacrypto.jsAES-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 noupgrade_required403 handling yet; that's new UI work. - Queue plumbing:
createQueueModule()factory; register inworker.js; concurrency 1 for rate-limited consumers; Mongo doc_iddoubles as BullMQjobId; tokens read live off Store, never in jobData (newer convention, priceUpdateQueue.js:50-52). orders/paidwebhook (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.marketplaceConnectionssub-doc (encrypted creds, per-marketplace enable flags, per-game enable flags); Zod schemas;GET/PUT /api/marketplace-connectionsgated byrequireFeature('marketplaceSync'); validate-on-save (Manapool/CardTraderGET /info-style call); Settings UI card (masked creds, connect/disconnect, upgrade upsell state for the 403). - PR 2 β Listing push (CardTrader). New
marketplace_listingscollection;marketplace-syncqueue + processor; push pipeline: managed variants β identity mapping β price/qty β bulk upsert β record listing state. Manual "push now" + repeatable schedule (clonepriceUpdateSchedulerpattern). 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.*, Manapoolorder_created, HMAC-verified raw-body, same discipline as Shopify webhooks) + polling backstop; decrement Shopify via existingaddInventoryQuantity(negative delta); idempotentmarketplace_ordersrecord; CardTrader Zerohub_pendingtreated as sold. - PR 4 β Shopify sales β marketplace decrement. Extend
orders/paidprocessing 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/oksupportedGames()β e.g.['mtg']for ManapoolmapVariant(variantDoc, game)β external identity ornull(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 sharedMarketplaceRateLimiter
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_foilbool. One-time expansion-codeβexpansion_id map. - Pokemon β CardTrader: blueprint via
tcg_player_id= TCGCSV productId (note: internal identity remainssetID-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:
- Load connected marketplaces Γ enabled games; load
managed_by='lgs-forge'variants for those games. - Read current quantities from Shopify (bulk inventoryLevel query through
shopifyAPI.js) β Shopify stays the stock source of truth; we push its numbers. - Price from the existing per-game pricing pipeline output (same price as Shopify today; per-marketplace modifiers are an open question).
- Diff against
marketplace_listings(only push changed price/qty β respects rate budgets), batch per adapter, record results. - Job progress/results in the existing
sync_jobsshape for UI polling.
4. Failure handling β
- Marketplace 401 β flip connection to
error, surface in Settings, never retry-loop (mirrorneedsReauthdiscipline). - No sandboxes (Manapool/CardTrader) β adapter-level
dryRunmode 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 β
_setDepsinjection 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
marketplaceConnectionsand everymarketplace_listingsfield. - Β§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_idpresence 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.
