Appearance
Buylist Inventory Intake — Design
Date: 2026-08-17 Status: Implemented Supersedes a non-goal of: 2026-07-29-buylist-shopify-native-settlement-design.md ("Inventory intake of purchased cards into Shopify")
Problem
Settling a buy order paid the customer but never put the cards on the shelf. A merchant who bought 60 cards had to re-enter all 60 by hand in Shopify admin, or through the catalog singles page one card at a time.
Scope
An Add to Inventory button on a settled (paid) buy order stocks the cards that order bought: singles and sealed, across all registered games.
Non-goals: no change to settlement; no reconciliation of stock a merchant later edits in Shopify; no per-line quantity editing at intake time (quantities were fixed when the offer was accepted).
Decisions
Separate from settling, not part of it
Settling moves money; intake moves stock. They happen at different times — the payout happens with the customer at the counter, the stocking happens once the cards are sorted. Coupling them would also mean a long Shopify job hanging off the payout confirmation, so an inventory failure could cast doubt on a completed payout. Intake is therefore its own button, its own job, and its own sub-document (BuylistOrder.intake), with settlement code untouched.
Reuse both existing write paths; invent neither
- Singles →
syncService.syncSetDirect(setCode, { game, cardNumber, condition, quantity, finish }), the same call the catalog singles-page "add N to inventory" shortcut makes. It creates or updates the product, then_applyCardInventoryShortcut→shopifyAPI.ensureVariantForConditionAndInventorycreates the condition variant if needed and delta-adjusts stock. - Sealed →
quickAddSealedProduct, the same call the sealed quick-add route makes.
This keeps one implementation of variant creation, barcode identity, condition pricing and publication targeting (§5.8).
quickAddSealedProduct extracted, and price made optional
It was route-local in routes/sealedProducts.js and always rewrote the variant price, setting pricingSource: 'manual'. Intake must never pass the buylist price: the only price this order knows is what the shop paid the customer, and writing that as the variant price would reprice the shelf at cost.
Moved to services/sealedQuickAddService.js with price optional:
| caller | price | existing product | new product |
|---|---|---|---|
| quick-add routes | always passed | rewritten (unchanged behavior) | priced as given |
| buylist intake | omitted | untouched | catalog price via applySealedPricingRules, real pricingSource |
Finish is passed through verbatim — no mapping table
The original design called for translating line.finish into a plugin finish id. Live data says don't:
| collection | distinct finish values |
|---|---|
shopify_mtg_products.metafields.finish | 15 — Nonfoil, Etched Foil, Surge Foil, Galaxy Foil, … |
shopify_mtg_products_variants.variants.finish | 22 |
shopify_pokemon_products* | normal, holofoil, reverseHolofoil, 1stEditionNormal, 1stEditionHolofoil |
shopify_riftbound_products_variants | normal, foil |
MTG has 22 catalog finishes, not the plugin's three ids (normal/foil/etched). A mapping table would collapse all 19 premium foils onto Normal — silently, since a wrong finish literal matches nothing rather than erroring (§5.4).
Both sides are already the same vocabulary: line.finish is copied verbatim from variants[].finish (buylistQuoteService.buildMatchResult), and resolveFinishLabel compares against metafields.finish, which buildVariantProducts sets from that same field. Passing it through gets an exact case-insensitive match.
Product limit enforced, not merely warned
There is no confirm step (a deliberate choice — the merchant wanted one click). The plan cap still holds: intake creates products exactly as POST /api/sync does and must not become a way around it. The run calls planService.checkProductLimit for the distinct new products it needs, stocks what fits, and marks the overflow skipped: 'product_limit'. Distinct products are admitted whole, so a merchant never gets a card's NM copies but not its LP ones. If the limit check itself errors, the run fails rather than creating unbounded products.
Re-runnable by design
intake.lines[] records every line's outcome. Only stocked is terminal — skipped and failed lines are retried on the next click, stocked lines are never touched again. A partial run is fixed by pressing the button again, not by hand reconciliation. Every field is schema-declared (§5.2) because a silently dropped status or lineIndex would make a re-run double-stock.
Records persist after every line, not once at the end. Batching them to the end means a process death at line 40 of 60 loses all 40 records, and the next run re-stocks those 40 — the shelf silently doubles. One small Mongo write per line is negligible against the Shopify round trips that line already cost.
Double-stocking: the three ways it could happen, and the guard for each
| hazard | guard |
|---|---|
| Two clicks / two tabs racing | Atomic claim: the in-flight check is inside the same findOneAndUpdate that sets queued, so only one caller wins. A read-then-write here would let both through. |
| Worker dies mid-run, automatic retry re-enters | attempts: 1 on the job (overriding the queue's attempts: 3 + 60s backoff) — a run that moves real stock should surface to the merchant, not silently re-enter. Combined with per-line persistence, any retry only picks up what never stocked. |
Crashed run wedges the order in running forever | intake.heartbeatAt, refreshed on every per-line write. A claim also succeeds when the heartbeat is older than INTAKE_STALE_MS (10 min). Because the heartbeat is per line rather than per run, a 500-line order never looks dead while it is genuinely working. Verified against real MongoDB: fresh running/queued blocked; stale ones and completed/no-intake claimable. |
A failed enqueue releases the claim (to failed), so Redis being down can't lock a merchant out of retrying.
A "stocked zero units" line is a failure, not a success
quickAddSealedProduct's create path skips the inventory add when Shopify returns no variant id, and reports no warning for it. Recorded as stocked, that line would be skipped by every future run, leaving the merchant permanently short those units — so quantityAdded === 0 is recorded as failed.
Rides the sync queue
buylist-intake is a job name on the existing mtg-sync queue, branched at the top of syncProcessor. It does the same kind of work as a sync, so it should share the sync worker's concurrency and Shopify rate limiter rather than compete with it. A fourth queue would mean a new worker, new Redis wiring and a deploy-shape change on both API and worker (§5.7).
BullMQ automatic retries are turned off for this job (attempts: 1) rather than merely unused — the queue's default is attempts: 3 with 60s exponential backoff, which a job on this queue inherits unless it says otherwise.
Game parity (§5.1)
Plugin-driven throughout; no if (game === …) anywhere in the new code.
| game | singles | sealed |
|---|---|---|
| mtg | mirrored | mirrored (supportsSealed: true) |
| pokemon | mirrored | mirrored (supportsSealed: true) |
| riftbound | mirrored | mirrored (supportsSealed: true) |
All three plugins report supportsSealed: true (verified in server/plugins/*/index.js). A future plugin without it skips its sealed lines with sealed_unsupported rather than failing the run. game is never defaulted — a line without one is skipped as unsupported_game (§5.5).
Surface
POST /api/buylist/orders/:id/intake— queue a run.buylistIntakeSchema(.strict(), empty body: what gets stocked is derived from the order's own lines) plusobjectIdParamSchemaon the param, so a malformed id is a 400 rather than a CastError surfacing as a 500. 409 if unsettled or already running.GET /api/buylist/orders/:id/intake— poll; returns the sub-document plus a summary.InventoryIntakeCardon the review page: button, progress, and a result panel that groups skips by reason.
Files
| file | change |
|---|---|
server/services/buylistIntakeService.js | new — planning, limit application, per-line stocking |
server/services/sealedQuickAddService.js | new — extracted from routes/sealedProducts.js, optional price |
server/models/BuylistOrder.js | intake sub-document |
server/schemas/buylistOrder.js | buylistIntakeSchema |
server/routes/buylist.js | two endpoints |
server/queues/syncQueue.js | addBuylistIntakeJob, BUYLIST_INTAKE_JOB |
server/queues/processors/syncProcessor.js | job-name branch → buylistIntakeProcessor |
client/src/pages/buylist/InventoryIntakeCard.jsx | new |
client/src/pages/buylist/BuylistReviewPage.jsx | renders the card for paid orders |
