Skip to content

CardTrader Listing Push (Marketplace Sync PR 2) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A merchant with a connected CardTrader account can push their managed inventory (price + quantity) to CardTrader — manually ("Push now") and on a daily schedule — with per-listing state recorded in a new marketplace_listings collection.

Architecture: Reuse the price-update pipeline's enumeration + pricing (ensureManagedCollectionpriceUpdateService.fetchAllProductsWithPaginationcalculatePriceUpdates internals), add a quantity read, map variants to CardTrader blueprints per game via the adapter SYNC INTERFACE, diff against marketplace_listings, and write via CardTrader's async bulk endpoints (error_mode: "strict", user_data_field = our variant key, poll GET /jobs/:uuid at 1 req/s). New BullMQ queue marketplace-push mirrors priceUpdateQueue; scheduling clones priceUpdateScheduler. Job progress reuses SyncJob (jobType: 'marketplace_push').

Tech Stack: Node/Express CommonJS, Mongoose, BullMQ, Zod, Vitest (ESM tests), React 18 + retroui.

Global Constraints

  • All CardTrader HTTP calls live in server/marketplaces/cardtrader/client.js — nothing else calls api.cardtrader.com. Shopify calls only via server/services/shopifyAPI.js.
  • No if (marketplace === ...) outside server/marketplaces/ — the processor is adapter-driven through the SYNC INTERFACE (mirror of the game-plugin rule).
  • game and marketplace are threaded explicitly, never defaulted (§5.5). SyncJob's game default 'mtg' (SyncJob.js:44-48) must NOT be relied on — always set it.
  • Every data-matching literal (condition names, mtg_language codes, property keys, category ids) comes from Task 1's live verification or the API-docs citations in this plan (§5.4). CardTrader vocabulary verbatim: conditions "Near Mint" | "Slightly Played" | "Moderately Played" | "Played" | "Heavily Played" | "Poor"; mtg_language codes de,en,es,fr,it,jp,pt (note jp, not ja); property keys condition, mtg_language, mtg_foil (boolean, may be read-only per blueprint); games MTG game_id=1, singles category_id=1.
  • CardTrader rate budget: 200 requests / 10 s global; GET /jobs/:uuid polling at 1 req/s. Writes use error_mode: "strict".
  • CardTrader price is a decimal in the SELLER's account currency on write; reads return price_cents + price_currency. The push must detect currency ≠ store currency and mark those listings error rather than silently pushing wrong numbers.
  • New sub-doc/model fields declared field-by-field + §5.2 round-trip test. Secrets: Enc suffix, utils/crypto.
  • Every new endpoint: Zod schema in server/schemas/ + barrel + validate(); literal routes before /:param routes (priceSync.js:9-11 rule).
  • Coverage ≥70% all four metrics per touched file — read scoped summaries (--coverage.include=...); the repo-wide per-file table renders blank rows on this machine, and the Vitest 4 threshold gate is dead.
  • CommonJS server / ESM tests via createRequire; _setDeps/_resetDeps DI; husky lint, no --no-verify; commit messages = one imperative merchant-visible sentence.
  • Carried from PR 1 review (must land in this PR): push path refuses to run when enabled is true but no accessTokenEnc is stored; Riftbound blueprint join key verified with a real API call before any Riftbound mapping code is written.

§5.1 Game-parity statement (for the PR description)

mtg: full mapping (scryfall_id join, via mtg_sets resolution). pokemon: full mapping (tcg_player_id = TCGCSV productId join). riftbound: mapping gated on Task 1's live join-key verification — if tcg_player_id is absent on Riftbound blueprints, riftbound ships as explicitly exempt-with-reason in this PR and a follow-up issue is filed. Every mapper lives in the adapter; the processor is game-blind.


Task 1: Live API verification script (resolves the 7 documented unknowns)

Files:

  • Create: server/scripts/dev/verifyCardTraderAssumptions.js (committed — reusable for future marketplaces)

No unit tests (it IS a verification tool); run output is pasted into the task report and drives constants in later tasks.

What it does (token from CT_TOKEN env var, read-only except one optional tiny write probe that is OFF by default):

  1. GET /categories?game_id=1 and ?game_id=5 (+ Riftbound's game id from GET /games) → print full category list (confirm MTG singles=1; find Pokemon/Riftbound singles category ids).
  2. GET /expansions → confirm fields {id, game_id, code, name}, print 3 samples per game, confirm lowercase codes.
  3. GET /blueprints/export?expansion_id=<one MTG, one Pokemon, one Riftbound> → print one card blueprint each: confirm exact keys scryfall_id, tcg_player_id, presence/location of collector_number and any fixed_properties; for Riftbound: does tcg_player_id exist and equal a TCGCSV productId? Also print editable_properties names — is mtg_foil editable on a normal card blueprint, and do foil printings appear as separate blueprints?
  4. GET /products/export (empty seller: expect []) → confirm endpoint + auth works; note response field names.
  5. Print GET /games list with ids.
  6. Rate-limit shape: intentionally NOT probed (don't hammer the API); handle by response-body inspection per docs.
  • [ ] Step 1: Write the script (plain node, reuse client.js's _fetch pattern inline or import the client after Task 4 extends it — for this task standalone fetch with Bearer header is fine since it lives under server/scripts/, which is outside the "client.js only" rule's scope? No — it isn't. Import and use server/marketplaces/cardtrader/client.js functions where they exist (getInfo), and add the read endpoints in Task 4 FIRST if sequencing allows; otherwise the script may use raw fetch TEMPORARILY with a // TODO(task-4): route through client.js and Task 4 rewires it. Prefer reordering: do Task 4's read-only methods first, then this script consumes them.)
  • [ ] Step 2: Run with Brent's token: CT_TOKEN=... node server/scripts/dev/verifyCardTraderAssumptions.js. Paste full output in the task report.
  • [ ] Step 3: Record decisions in server/marketplaces/cardtrader/constants.js (created in Task 4): GAME_IDS, SINGLES_CATEGORY_IDS, CONDITION_MAP, LANGUAGE_DEFAULT, MAX_BULK_BATCH (start at 100 — conservative, undocumented), each with a comment citing this script's run date.
  • [ ] Step 4: Commit: git commit -m "Add CardTrader API assumption verification script"

Decision gate: if Riftbound blueprints lack a usable join key → riftbound is exempt in this PR (update §5.1 statement + spec Open Items + file follow-up issue). If mtg_foil proves read-only on separate foil blueprints → the MTG mapper (Task 5) must index blueprints by (scryfall_id, foilness) — the Task 5 code below has a marked switch point for this.


Task 2: MarketplaceListing model

Files:

  • Create: server/models/MarketplaceListing.js
  • Test: server/models/MarketplaceListing.test.js

Interfaces — Produces (Tasks 6-7 consume these exact names):

js
// collection 'marketplace_listings'
{
  shop: String (required, index),
  marketplace: String (required),            // 'cardtrader'
  game: String (required),                   // never defaulted (§5.5)
  shopifyProductId: String, shopifyVariantId: String (required), sku: String,
  externalId: String,                        // CardTrader product id (null until created)
  externalIdentity: {                        // §5.2: declared field-by-field
    blueprintId: Number,
    condition: String,                       // CardTrader vocab ("Near Mint"...)
    language: String,                        // 'en' etc.
    foil: Boolean,
  },
  lastPushedPriceCents: Number, lastPushedQuantity: Number,
  currency: String,                          // price_currency observed on CardTrader side
  status: String enum ['active','error','delisted','pending'],
  lastError: String, lastSyncedAt: Date,
}
// indexes: { shop: 1, marketplace: 1, shopifyVariantId: 1 } unique;
//          { shop: 1, marketplace: 1, status: 1 }
  • [ ] Steps: failing round-trip test (pattern Store.marketplaceConnections.test.js — every field incl. each externalIdentity sub-field, plus unique-index shape assertion via MarketplaceListing.schema.indexes()), implement, npx vitest run server/models/MarketplaceListing.test.js + full models dir, commit: Track per-variant marketplace listing state in its own collection

Task 3: SyncJob support for marketplace pushes

Files:

  • Modify: server/models/SyncJob.js — add 'marketplace_push' to the jobType enum (:14); add marketplace: { type: String, default: null } and marketplacePushStats: { type: Schema.Types.Mixed, default: null } (declared, mirrors priceUpdateStats :86)

  • Modify: server/routes/sync.js GET /sync/:jobId response (:531-575) — include marketplace and marketplacePushStats when present

  • Test: extend server/models/SyncJob.test.js (round-trip the two new fields + enum accepts marketplace_push) and the sync route test for the response fields

  • [ ] Steps: failing tests → implement → run model + route tests → commit: Let sync job tracking carry marketplace push runs


Task 4: CardTrader client extensions + marketplace rate limiter

Files:

  • Create: server/utils/marketplaceRateLimiter.js + test
  • Create: server/marketplaces/cardtrader/constants.js (Task 1 fills values)
  • Modify: server/marketplaces/cardtrader/client.js + test

Interfaces — Produces:

js
// marketplaceRateLimiter.js — request-per-window token bucket, keyed externally
class MarketplaceRateLimiter {
  constructor({ maxRequests, windowMs })   // CardTrader: { maxRequests: 190, windowMs: 10000 } (headroom under 200)
  async waitIfNeeded()                     // resolves when a slot is free; FIFO
}
class MarketplaceRateLimiterFactory { getFor(shop, marketplace) } // Map keyed `${shop}:${marketplace}`
module.exports = { MarketplaceRateLimiter, marketplaceRateLimiterFactory }
js
// client.js additions — every method: (accessToken, ...) → parsed JSON;
// 401/403 → CardTraderAuthError; each call passes through the shared limiter
// via an internal _limited(shop, fn) helper — signature becomes
// getExpansions(accessToken, { shop }), etc.; shop is required for limiter keying.
async getGames(accessToken, { shop })
async getCategories(accessToken, { shop, gameId })
async getExpansions(accessToken, { shop })
async getBlueprintsForExpansion(accessToken, { shop, expansionId })
async getProductsExport(accessToken, { shop })
async bulkCreateProducts(accessToken, { shop, products })   // → job uuid (response field 'job')
async bulkUpdateProducts(accessToken, { shop, products })   // → job uuid
async getJob(accessToken, { shop, jobUuid })                // → {uuid, state, stats, results[]}
async pollJobUntilDone(accessToken, { shop, jobUuid, timeoutMs = 300000 })
  // polls getJob at ≥1000ms intervals (doc limit 1/s); resolves on state
  // 'completed'|'unprocessable'; rejects on timeout. Uses injectable _deps.sleep.

Request/response field names verbatim from the API reference (research 2026-07-31): bulk request body { products: [...] }, response { job: "<uuid>" }; job results keyed by job_index, entries {result: 'ok'|'error'|'warning', product_id?, errors?, warnings?}; products/export items use price_cents, price_currency, properties_hash, user_data_field.

  • [ ] Steps: TDD per method group (limiter first — test FIFO + window release with fake timers; then client methods with _setDeps fetch mocks incl. a pollJobUntilDone sequence pending→completed and the 1s-interval assertion via injected sleep). Full npx vitest run server/marketplaces/ server/utils/marketplaceRateLimiter.test.js. Commit: Add rate-limited CardTrader catalog and bulk inventory calls
  • [ ] Then reorder-note: run Task 1's script now (it consumes these read methods) if not already run.

Task 5: Adapter SYNC INTERFACE + per-game blueprint mapping

Files:

  • Modify: server/marketplaces/BaseMarketplaceAdapter.js — add the SYNC INTERFACE members that THIS PR consumes (and only those): buildMappingContext, mapVariant, pushListings, rateLimiterConfig (orders/webhook members stay PR 3, per §5.9)
  • Modify: server/marketplaces/cardtrader/index.js + test
  • Create: server/marketplaces/cardtrader/mapping.js + test (pure mapping logic, DI for models)

Interfaces — Produces:

js
// BaseMarketplaceAdapter additions (abstract, throwing):
async buildMappingContext({ shop, accessToken, games })  // one-time per push run: expansion code→id maps, blueprint indexes
mapVariant(variantData, context)                          // → { blueprintId, properties, userDataField } | { unmappable: reason }
async pushListings({ accessToken, shop, batch })          // batch: [{listing, priceCents→decimal, quantity}] → per-item results
rateLimiterConfig()                                       // → { maxRequests, windowMs }

CardTrader mapping (in mapping.js, consumed by the adapter):

  • Context build: getExpansions → filter by game_id ∈ enabled games' CardTrader ids → map code.toLowerCase() → expansion id (our MTG set_code metafield is uppercase — normalize; cite priceUpdateService.js:335-349 for where setCode comes from). For each expansion actually present in the store's variants: getBlueprintsForExpansion, filter category_id ∈ singles ids (constants from Task 1), index by scryfall_id (MTG) / String(tcg_player_id) (Pokemon; Riftbound if Task 1 confirmed).

  • MTG scryfall resolution: sourceCardUUID is the MTGJSON uuid, NOT scryfall (transformSetsToProducts.js:373; plugins/mtg/index.js:270). Resolve per set from mtg_sets: aggregation $unwind '$data.cards', project { uuid: '$data.cards.uuid', scryfallId: '$data.cards.identifiers.scryfallId' } filtered by set code (existing pattern: routes/catalog.js:411-446). Build uuid → scryfallId map per set in the context. Variant SKU base = card uuid (ShopifyMTGProductVariant.js:37-42) — strip finish suffix to recover the uuid exactly the way the price pipeline's skuToProduct join does (priceUpdateService.js:516-531): match catalog docs by variants.sku ∈ skus, then plugin.getSourceCardId(product).

  • Vocabulary maps (constants.js, all cited): condition key→CardTrader { nm: 'Near Mint', lp: 'Slightly Played', mp: 'Moderately Played', hp: 'Heavily Played', damaged: 'Poor' } — note LP maps to CardTrader's "Slightly Played" and our hp to "Heavily Played"; CardTrader's "Played" tier has no equivalent in our vocab (CONDITION_LABELS, priceLookupService.js:367-373) and is unused. Language: default 'en' (we don't track language). Foil: our Shopify Finish option value ≠ 'Normal' → mtg_foil: true only if mtg_foil is editable on that blueprint (Task 1 decision gate; if foil = separate blueprints, index by (scryfall_id, foil) instead — switch point marked in code).

  • Unmappable variants (no expansion, no blueprint, no scryfall resolution) are returned with a reason string → recorded on the listing as status:'error', lastError — never silently dropped.

  • pushListings: split creates (no externalId) from updates (has externalId); chunk at MAX_BULK_BATCH; bulkCreateProducts/bulkUpdateProducts with per-item { blueprint_id, price: <decimal>, quantity, error_mode: 'strict', user_data_field: listing.shopifyVariantId, properties }; pollJobUntilDone; map results[] back via job_index[{ listingKey, ok, productId?, errors? }].

  • [ ] Steps: TDD — mapping.js pure functions first (uuid→scryfall map build with mocked SetModel aggregate; condition/language/foil property builds; unmappable reasons), then adapter methods with client mocked via _setDeps. Commit: Map store inventory to CardTrader blueprints for all supported games


Task 6: Bulk quantity read on ShopifyAPI

Files:

  • Modify: server/services/shopifyAPI.js + its test — new method:
js
async getManagedVariantQuantities(collectionId)   // → Map<variantId, availableQuantity>

Paginated collection query (mirror priceUpdateService.js:239-271's shape) selecting variants(first:25){ id inventoryQuantity }inventoryQuantity is the aggregate available across locations, sufficient for the shared-pool decision (spec Decisions table). Rate-limited via the existing graphQL() choke point with a real cost estimate.

  • [ ] Steps: failing test (mock graphQL, two pages, assert map contents + pagination cursor use) → implement → npx vitest run server/services/shopifyAPI.test.js → commit: Read managed product quantities from Shopify in bulk

Task 7: marketplace-push queue, processor, worker registration

Files:

  • Create: server/queues/marketplacePushQueue.js (clone priceUpdateQueue.js — QUEUE_NAME 'marketplace-push', job name 'marketplace-push', repeat-key marketplace-push_<shop>_<marketplace>, attempts 2/exp 30s, same retention; exports addMarketplacePushJob, addRepeatingMarketplacePush, removeAllRepeatingMarketplacePushesForShop) + test
  • Create: server/queues/processors/marketplacePushProcessor.js + test
  • Modify: server/worker.js — all FIVE touchpoints: requires (:22-27), worker handle (:52-54), construction with concurrency: 1 (:255-266 pattern), event handlers incl. Sentry tag queue: 'marketplace-push' (:341-388 pattern), heartbeat map 4th key (:416-433), shutdown list (:536-540)
  • Modify: server/routes/admin.js:29-34 — Bull-Board adapter for the new queue

Processor pipeline (async (job) with job.data = { shop, marketplace, isScheduled?, syncJobId? }; NO token in jobData — read live off Store, priceUpdateQueue.js:50-53 convention):

  1. Load store; guard: needsReauth → fail fast; conn = store.marketplaceConnections[marketplace]; guard (PR 1 carry): !conn?.enabled || !conn.accessTokenEnc → fail the job with no_active_connection — never push with enabled-but-credential-less state.
  2. games = conn.enabledGames ∩ store.enabledCatalogs ∩ adapter.supportedGames() (Store.js:356-357 comment contract). Empty → complete with no_games.
  3. Decrypt both tokens. ensureManagedCollection (managedCollectionService, self-heal pattern priceUpdateProcessor.js:207-221) → priceUpdateService.fetchAllProductsWithPagination → filter variantMap to games.
  4. Prices: reuse calculatePriceUpdates internals BUT the push needs the full priced set, not the diff (it drops unchanged at :457-460) — add an options param { includeUnchanged: true } to calculatePriceUpdates (backward-compatible, default false; the price processor's behavior unchanged — add a regression test for that).
  5. Quantities: shopifyAPIInstance.getManagedVariantQuantities(collectionId) (Task 6).
  6. adapter.buildMappingContext(...); for each variant: existing MarketplaceListing lookup (by shop+marketplace+shopifyVariantId) → decide create/update/skip (skip when priceCents AND quantity match lastPushed*); mapVariant for creates; unmappables → upsert listing status:'error'.
  7. adapter.pushListings in chunks; on results: upsert listings (externalId, lastPushedPriceCents/Quantity, status:'active'|'error', lastSyncedAt, lastError); currency guard — first successful create triggers one getProductsExport sample; if price_currency ≠ store currency (Shopify shop.currencyCode — fetch once via existing shopifyAPI method or add to the collection query), mark run currency_mismatch and set connection lastPushStatus accordingly.
  8. SyncJob updates throughout (marketplacePushStats: counts pushed/created/updated/skipped/unmappable/errors, per-phase timestamps — atomic-update pattern priceUpdateProcessor.js:47-53); honor cancellation (checkIfCancelled pattern :34-39).
  9. Finish: Store.findOneAndUpdate marketplaceConnections.<m>.lastPushAt = now, lastPushStatus = 'success' | 'partial' | 'failed' | 'currency_mismatch'; scheduled runs also update nextRunAt (priceUpdateProcessor.js:439-453 pattern).
  10. Dry-run mode (spec §Failure handling): job.data.dryRun === true runs phases 1-6 fully but skips pushListings and all listing/Store writes, recording the would-be creates/updates/skips/unmappables in marketplacePushStats.dryRun. The push route accepts { dryRun: true } (Zod boolean optional) and the live smoke test uses it first. Tests cover: dry run performs zero writes (Store, MarketplaceListing, adapter push all unspied-called).
  • [ ] Steps: TDD the processor with full DI (_setDeps for Store, models, services, adapter registry) — tests: credential guard, game intersection, diff/skip logic, unmappable recording, result upserts, lastPush* writes, cancellation. Queue module tests mirror priceUpdateQueue's. Worker registration proven by a boot smoke assertion if a worker.js test harness exists — otherwise verified in Task 10's checklist. Commits (2): Add the marketplace push queue and processor / Register the marketplace push worker

Task 8: Push + schedule + listings routes

Files:

  • Create: server/schemas/marketplacePush.js (+ barrel): marketplacePushScheduleSchema (clone priceSync.js:81-108 — enabled?, hour? 0-23, timezone? with isValidTimezone refine, superRefine hour+timezone required when enabling)
  • Create: server/services/marketplacePushScheduler.js (clone priceUpdateScheduler.js — repeat-key marketplace-push_<shopKey>_<marketplace>; schedule state in NEW Store sub-doc marketplacePushSchedule.<marketplace> = { enabled, hour, timezone, lastRunAt, nextRunAt, repeatableJobId }, declared field-by-field + round-trip test)
  • Modify: server/routes/marketplaces.js + test — all gated requireFeature('marketplaceSync'), literals before :marketplace params:
    • POST /marketplace-connections/:marketplace/push → guards (connected + enabled + games non-empty; 409 push_already_running if an active SyncJob exists for shop+marketplace) → SyncJob.create({shop, jobType:'marketplace_push', marketplace, game: games.join(','), status:'queued'})... no — game never gets a joined string; use game: null? SyncJob.game defaults 'mtg' (§5.5 hazard) — set game: 'multi'? Resolution: add 'multi' to nothing — set game to the single game when exactly one is enabled, else omit the field entirely and rely on marketplace + marketplacePushStats.games for display. Then addMarketplacePushJob({shop, marketplace, syncJobId}); respond {jobId, status:'queued'} (priceSync.js:30-58 shape). Status polling reuses existing GET /api/sync/:jobId.
    • GET /marketplace-connections/:marketplace/schedule / PUT .../schedule → scheduler (priceSync.js:114-177 shape).
    • GET /marketplace-connections/:marketplace/listings?status=&page= → paginated marketplace_listings for the store (error-triage UI; validate query via existing pagination schema).
  • [ ] Steps: TDD handlers (exported-handler pattern), incl. the 409 guard and the §5.5 game-field resolution → full suite → commit: Let merchants trigger and schedule CardTrader pushes from the API

Task 9: Client — Push now, schedule, and listing status

Files:

  • Modify: client/src/components/MarketplaceSettings.jsx + test

Slot (per exploration): inside the conn.connected branch between the games checkboxes (:241-257) and the last-push line (:259-264). Clone idioms from the file itself + NotificationSettings.jsx:

  • Push now button → api.post('/marketplace-connections/cardtrader/push') → poll api.get('/sync/'+jobId) every 3s while mounted; inline progress (marketplacePushStats counts); 409 → "A push is already running"; button disabled when no games enabled (client mirror of the server guard — comment cites the server rule).
  • Schedule row: enable switch + hour/timezone selects (clone the price-sync schedule controls' component structure; reuse its timezone list source).
  • Errors line: when last push had unmappable/error counts, show "N listings need attention" linking a simple modal/table fed by the listings endpoint (status=error).
  • [ ] Steps: failing tests (push-now fires POST + polls; 409 copy; schedule PUT payload; error count renders) → implement → npm run test:client -- MarketplaceSettings + full client suite + build → per-file coverage ≥70% → commit: Add push now and daily schedule controls to the CardTrader card

Task 10: Verification pass + live smoke + PR

  • [ ] npm test, npm run test:client, npm run lint (zero new warnings), npm run build, scoped coverage summaries for every touched file (≥70% ×4).
  • [ ] Greps: myshopify.com only via shopifyAPI; cardtrader.com only in client.js; no || 'mtg'/?? 'mtg'; no $sort added on any time-series collection (§5.6 — this PR shouldn't touch them; verify).
  • [ ] Worker boot check: npm run dev (or worker alone) → logs show 4 workers ready incl. marketplace-push; Bull-Board lists it.
  • [ ] Live smoke (Brent + real token, dev store ufkes-dev-custom-app): enable MTG only, Push now with a small managed set → verify listings appear on CardTrader (correct price/qty/condition), marketplace_listings rows created with externalId, second push with no changes → all skipped, price change in Shopify → push updates CardTrader, lastPushAt/lastPushStatus render. Then delist test is PR 3+ scope (no delete flow in this PR — quantity 0 via Shopify zeroing covers removal semantics; confirm a 0-qty push zeroes the CardTrader listing).
  • [ ] PR: title Push managed inventory to CardTrader on demand and on a schedule; body: spec/plan links, §5.1 statement (incl. the Riftbound gate outcome), the Task 1 verification outputs, carried-item closures (credential guard ✓), and what stays for PR 3 (order webhooks → Shopify decrement, Zero hub_pending) and PR 4.

Sequencing note

Task order for execution: 4 (client read methods) → 1 (live verification, gates mapping design) → 2 → 3 → 5 → 6 → 7 → 8 → 9 → 10. Task 1's outcomes may edit Task 5's approach (foil-blueprint switch point) — re-read Task 5 after Task 1 lands.

Open decisions deliberately deferred

  • Delisting/delete flow (bulk_destroy) — PR 3+; quantity-0 semantics cover PR 2.
  • Multi-currency support beyond detect-and-flag — follow-up if a real merchant hits it.
  • mapError copy generalization in the client — triggers with marketplace #2 (Manapool), not here.