Appearance
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 (ensureManagedCollection → priceUpdateService.fetchAllProductsWithPagination → calculatePriceUpdates 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 callsapi.cardtrader.com. Shopify calls only viaserver/services/shopifyAPI.js. - No
if (marketplace === ...)outsideserver/marketplaces/— the processor is adapter-driven through the SYNC INTERFACE (mirror of the game-plugin rule). gameandmarketplaceare threaded explicitly, never defaulted (§5.5). SyncJob'sgamedefault'mtg'(SyncJob.js:44-48) must NOT be relied on — always set it.- Every data-matching literal (condition names,
mtg_languagecodes, 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_languagecodesde,en,es,fr,it,jp,pt(notejp, notja); property keyscondition,mtg_language,mtg_foil(boolean, may be read-only per blueprint); gamesMTG game_id=1, singlescategory_id=1. - CardTrader rate budget: 200 requests / 10 s global;
GET /jobs/:uuidpolling at 1 req/s. Writes useerror_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 listingserrorrather than silently pushing wrong numbers. - New sub-doc/model fields declared field-by-field + §5.2 round-trip test. Secrets:
Encsuffix,utils/crypto. - Every new endpoint: Zod schema in
server/schemas/+ barrel +validate(); literal routes before/:paramroutes (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/_resetDepsDI; 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
enabledis true but noaccessTokenEncis 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):
GET /categories?game_id=1and?game_id=5(+ Riftbound's game id fromGET /games) → print full category list (confirm MTG singles=1; find Pokemon/Riftbound singles category ids).GET /expansions→ confirm fields{id, game_id, code, name}, print 3 samples per game, confirm lowercase codes.GET /blueprints/export?expansion_id=<one MTG, one Pokemon, one Riftbound>→ print one card blueprint each: confirm exact keysscryfall_id,tcg_player_id, presence/location ofcollector_numberand anyfixed_properties; for Riftbound: doestcg_player_idexist and equal a TCGCSV productId? Also printeditable_propertiesnames — ismtg_foileditable on a normal card blueprint, and do foil printings appear as separate blueprints?GET /products/export(empty seller: expect[]) → confirm endpoint + auth works; note response field names.- Print
GET /gameslist with ids. - 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_fetchpattern inline or import the client after Task 4 extends it — for this task standalonefetchwith Bearer header is fine since it lives underserver/scripts/, which is outside the "client.js only" rule's scope? No — it isn't. Import and useserver/marketplaces/cardtrader/client.jsfunctions 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.jsand 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. eachexternalIdentitysub-field, plus unique-index shape assertion viaMarketplaceListing.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 thejobTypeenum (:14); addmarketplace: { type: String, default: null }andmarketplacePushStats: { type: Schema.Types.Mixed, default: null }(declared, mirrorspriceUpdateStats:86)Modify:
server/routes/sync.jsGET /sync/:jobId response (:531-575) — includemarketplaceandmarketplacePushStatswhen presentTest: extend
server/models/SyncJob.test.js(round-trip the two new fields + enum acceptsmarketplace_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
_setDepsfetch mocks incl. apollJobUntilDonesequence pending→completed and the 1s-interval assertion via injected sleep). Fullnpx 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 bygame_id∈ enabled games' CardTrader ids → mapcode.toLowerCase()→ expansion id (our MTGset_codemetafield is uppercase — normalize; cite priceUpdateService.js:335-349 for wheresetCodecomes from). For each expansion actually present in the store's variants:getBlueprintsForExpansion, filtercategory_id∈ singles ids (constants from Task 1), index byscryfall_id(MTG) /String(tcg_player_id)(Pokemon; Riftbound if Task 1 confirmed).MTG scryfall resolution:
sourceCardUUIDis the MTGJSON uuid, NOT scryfall (transformSetsToProducts.js:373; plugins/mtg/index.js:270). Resolve per set frommtg_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). Builduuid → scryfallIdmap 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'sskuToProductjoin does (priceUpdateService.js:516-531): match catalog docs byvariants.sku∈ skus, thenplugin.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 ourhpto "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 ShopifyFinishoption value ≠ 'Normal' →mtg_foil: trueonly ifmtg_foilis 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 (noexternalId) from updates (hasexternalId); chunk atMAX_BULK_BATCH;bulkCreateProducts/bulkUpdateProductswith per-item{ blueprint_id, price: <decimal>, quantity, error_mode: 'strict', user_data_field: listing.shopifyVariantId, properties };pollJobUntilDone; mapresults[]back viajob_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(clonepriceUpdateQueue.js— QUEUE_NAME'marketplace-push', job name'marketplace-push', repeat-keymarketplace-push_<shop>_<marketplace>, attempts 2/exp 30s, same retention; exportsaddMarketplacePushJob,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 withconcurrency: 1(:255-266 pattern), event handlers incl. Sentry tagqueue: '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):
- Load store; guard:
needsReauth→ fail fast;conn = store.marketplaceConnections[marketplace]; guard (PR 1 carry):!conn?.enabled || !conn.accessTokenEnc→ fail the job withno_active_connection— never push with enabled-but-credential-less state. games = conn.enabledGames ∩ store.enabledCatalogs ∩ adapter.supportedGames()(Store.js:356-357 comment contract). Empty → complete withno_games.- Decrypt both tokens.
ensureManagedCollection(managedCollectionService, self-heal pattern priceUpdateProcessor.js:207-221) →priceUpdateService.fetchAllProductsWithPagination→ filter variantMap togames. - Prices: reuse
calculatePriceUpdatesinternals BUT the push needs the full priced set, not the diff (it drops unchanged at :457-460) — add an options param{ includeUnchanged: true }tocalculatePriceUpdates(backward-compatible, default false; the price processor's behavior unchanged — add a regression test for that). - Quantities:
shopifyAPIInstance.getManagedVariantQuantities(collectionId)(Task 6). adapter.buildMappingContext(...); for each variant: existingMarketplaceListinglookup (by shop+marketplace+shopifyVariantId) → decide create/update/skip (skip when priceCents AND quantity matchlastPushed*);mapVariantfor creates; unmappables → upsert listingstatus:'error'.adapter.pushListingsin chunks; on results: upsert listings (externalId,lastPushedPriceCents/Quantity,status:'active'|'error',lastSyncedAt,lastError); currency guard — first successful create triggers onegetProductsExportsample; ifprice_currency≠ store currency (Shopifyshop.currencyCode— fetch once via existing shopifyAPI method or add to the collection query), mark runcurrency_mismatchand set connectionlastPushStatusaccordingly.- SyncJob updates throughout (
marketplacePushStats: counts pushed/created/updated/skipped/unmappable/errors, per-phase timestamps — atomic-update pattern priceUpdateProcessor.js:47-53); honor cancellation (checkIfCancelledpattern :34-39). - Finish:
Store.findOneAndUpdatemarketplaceConnections.<m>.lastPushAt = now,lastPushStatus = 'success' | 'partial' | 'failed' | 'currency_mismatch'; scheduled runs also update nextRunAt (priceUpdateProcessor.js:439-453 pattern). - Dry-run mode (spec §Failure handling):
job.data.dryRun === trueruns phases 1-6 fully but skipspushListingsand all listing/Store writes, recording the would-be creates/updates/skips/unmappables inmarketplacePushStats.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 (
_setDepsfor 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?withisValidTimezonerefine, superRefine hour+timezone required when enabling) - Create:
server/services/marketplacePushScheduler.js(clone priceUpdateScheduler.js — repeat-keymarketplace-push_<shopKey>_<marketplace>; schedule state in NEW Store sub-docmarketplacePushSchedule.<marketplace>={ enabled, hour, timezone, lastRunAt, nextRunAt, repeatableJobId }, declared field-by-field + round-trip test) - Modify:
server/routes/marketplaces.js+ test — all gatedrequireFeature('marketplaceSync'), literals before:marketplaceparams:POST /marketplace-connections/:marketplace/push→ guards (connected + enabled + games non-empty; 409push_already_runningif an active SyncJob exists for shop+marketplace) →SyncJob.create({shop, jobType:'marketplace_push', marketplace, game: games.join(','), status:'queued'})... no —gamenever gets a joined string; usegame: null? SyncJob.game defaults 'mtg' (§5.5 hazard) — setgame: 'multi'? Resolution: add'multi'to nothing — setgameto the single game when exactly one is enabled, else omit the field entirely and rely onmarketplace+marketplacePushStats.gamesfor display. ThenaddMarketplacePushJob({shop, marketplace, syncJobId}); respond{jobId, status:'queued'}(priceSync.js:30-58 shape). Status polling reuses existingGET /api/sync/:jobId.GET /marketplace-connections/:marketplace/schedule/PUT .../schedule→ scheduler (priceSync.js:114-177 shape).GET /marketplace-connections/:marketplace/listings?status=&page=→ paginatedmarketplace_listingsfor 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')→ pollapi.get('/sync/'+jobId)every 3s while mounted; inline progress (marketplacePushStatscounts); 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.comonly via shopifyAPI;cardtrader.comonly in client.js; no|| 'mtg'/?? 'mtg'; no$sortadded 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_listingsrows created withexternalId, second push with no changes → all skipped, price change in Shopify → push updates CardTrader,lastPushAt/lastPushStatusrender. 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, Zerohub_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.
mapErrorcopy generalization in the client — triggers with marketplace #2 (Manapool), not here.
