Appearance
Cross-Game Buylist (Buy) Orders โ Design โ
Date: 2026-07-20 Status: Approved, pending implementation plan
Problem โ
Buylist orders (buy orders โ a shop buying cards from customers) are modeled and listed as strictly single-game today: BuylistOrder.game is a single required string (server/models/BuylistOrder.js:33), and every route/UI assumes one order = one game.
In practice this is already broken. Both order-creation UIs (client/src/pages/buylist/NewBuyInTab.jsx for merchants, client/src/pages/portal/BuylistPortalPage.jsx for the public customer portal) show a row of per-game tab buttons that scope card search, but switching tabs never clears the selectedCards cart. A user can add MTG cards, click the Pokemon tab, add Pokemon cards, and submit โ with a cart that already spans games.
The backend doesn't catch this cleanly either: POST /api/buylist/orders takes one game, and findCardByUuid (server/services/buylistQuoteService.js:555) re-resolves every line against that single game's catalog. A card from the "wrong" game isn't found, so it silently comes back matchStatus: 'unmatched' and is dropped from the order as "[unavailable card <uuid>]" โ no explanation that it's a game mismatch. This is silent data loss, not just a cosmetic UI issue.
Decision โ
Make cross-game buy orders a real, supported feature rather than closing the loophole: a single order can legitimately contain lines from multiple games, and the UI should make that visible and intentional instead of hiding it.
Scope โ
In scope: the search-and-select cart flow in both NewBuyInTab.jsx (merchant) and BuylistPortalPage.jsx (customer portal) โ this is where the bug lives and where customers/merchants build a list interactively.
Out of scope: the "Paste List" import mode (plain text / ManaBox CSV / TCGplayer CSV). It stays single-game per submission, unchanged from today. Reasons:
- It doesn't accumulate across submissions today anyway โ the raw-text textarea is replaced on each paste, and the request body sends either
rawTextorlines, never both (schemas/buylistOrder.js:33-36). - There's no reliable per-row game signal in a ManaBox/TCGplayer export to split a pasted list across games automatically.
Per-game buylist settings/pricing config (BuylistSettingsTab.jsx, buylistConfigService.js) is unaffected โ rates remain configured per game regardless of whether an order spans games.
Data model โ
server/models/BuylistOrder.js:
buylistLineSchemagains a required field:game: { type: String, required: true, index: true }. No default โ matches this repo's existing rule thatgameis never defaulted (CLAUDE.md ยง5.5).- The order-level
gamefield is removed. - Index change: drop the implicit
{shop, game}single-field use, add a multikey index{ shop: 1, 'lines.game': 1 }to support the list filter. The existing{ shop: 1, status: 1, createdAt: -1 }index is unaffected.
Alternative considered and rejected: a denormalized order.games: [String] summary array on the order, kept in sync at write time for faster listing. Rejected โ it's derived state requiring a sync-on-write obligation, with no read pattern that the multikey index doesn't already serve directly. This matches the repo's own "Dead Scaffold" caution (CLAUDE.md ยง5.9): don't add state without a consumer that needs it over the direct alternative.
Migration โ
One-time script under server/scripts/migrations/:
- For every existing
BuylistOrder, copyorder.gameonto every line inorder.lines. - Unset the order-level
gamefield. - Provide a
--dry-runmode that reports counts without writing, per this repo's existing migration convention (e.g.migrate:pricing-config).
Sequencing: run the migration (and confirm zero orders remain with lines missing game) before deploying the schema/route/client changes that assume every line already has one.
API (server/routes/api.js) โ
POST /api/buylist/orders โ
- Lines path (
selectedCardLineSchema, search-and-select): drop the?game=query requirement for this path. Each line now supplies its owngame, validated viaisGameSupported. The route:- Groups incoming lines by distinct
game. - For each distinct game, calls
assertBuylistEnabledagainst that game's config. If any game is disabled/unsupported, return one 400 that names every offending game โ no silent partial acceptance. - Calls
priceSelectedLinesper game group (existing per-game pricing logic, unchanged internally), then merges the results back into onelinesarray in the original submitted order. - Saves the order without a top-level
game.
- Groups incoming lines by distinct
- rawText path (paste import, out of scope for spanning): unchanged behavior.
gamemoves from being a bare query param to a required body field for this path only, for schema consistency now thatlinesno longer carries an implicit order-level game via the query string. Every linegenerateQuoteproduces is stamped with that one game, same as today.
selectedCardLineSchema (Zod, server/schemas/buylistOrder.js) gains:
js
game: z.string().min(1, 'game is required')validated against isGameSupported in the route (matching the existing pattern used for the query-param check today).
GET /api/buylist/orders โ
gamequery param becomes optional.- Omitted โ no game filter (new default, matches the UI's "All" tab).
- Present โ
{ shop, 'lines.game': game, status? }โ "contains at least one line of this game," not "is exclusively this game."
PATCH /api/buylist/orders/:id (merchant line edits) โ
Currently resolves one buylistConfig from order.game for the whole order (api.js:2128) before calling computeOrderTotals. Changes to resolve buylistConfig per line, keyed by that line's own game, since a mixed order now needs a different config per line group when totals are recomputed.
Unaffected โ
Confirmed by grep across server/routes/api.js: send-offer, accept, and decline never reference order.game โ they're pure status transitions. Removing the order-level field doesn't touch them.
Public portal has its own parallel implementation โ
The customer portal's order creation does not go through server/routes/api.js โ BuylistPortalPage.jsx posts to /public/buylist/:shop/orders, a separate, unauthenticated router (server/routes/publicBuylist.js) backed by its own service (server/services/publicBuylistService.js) and its own Zod schemas (server/schemas/publicBuylist.js, reusing selectedCardLineSchema from the merchant schema file). quotePublicLines/createPublicOrder in that service currently take the same single top-level game the merchant route did, and need the identical treatment: drop the top-level game, validate every distinct game in the submitted lines via a shared resolveLineGameConfigs(store, lines) helper (new, added to buylistConfigService.js so the merchant route and both public-portal functions validate games the same way instead of three near-duplicate implementations), and stop returning game on toPublicOrder. There's also an unused-by-the-current-UI POST /:shop/quote endpoint (quotePublicLines) that shares this same game-scoping logic โ updated for consistency even though BuylistPortalPage.jsx doesn't call it today, so the public API surface doesn't have one cross-game-capable endpoint and one still-single-game one.
Mixed-game store credit bonus โ
buylistConfigService.js's per-game config includes a storeCreditBonus rate, and computeOrderTotals applies it to turn cashTotal into creditTotal. That rate can differ per game (e.g. MTG pays a 25% store credit bonus, Pokemon pays 50%), so a single order-level bonus rate no longer makes sense once one order can span games. computeOrderTotals's second argument changes from a single buylistConfig to a { [game]: buylistConfig } map; creditTotal is computed line-by-line using each line's own game's bonus, and the stored payout.creditBonus becomes the blended rate implied by (creditTotal - cashTotal) / cashTotal โ which is still a single stored number (no schema change to payout), stays arithmetically consistent (creditTotal = cashTotal * (1 + creditBonus) still holds), and reduces to exactly one game's own rate whenever every line in the order shares that game.
Client โ
NewBuyInTab.jsx / BuylistPortalPage.jsx โ
addCard(card)stamps the currently active tab'sgameonto the new cart row at add-time.setGame(game tab click) no longer clearsselectedCardsโ this is the actual bug fix. The cart becomes the union of everything added across tab switches.- The cart table gains a small per-row game badge (e.g. a chip next to the card name), so a mixed cart is visible and legible rather than a hidden side effect. This is what actually resolves the "confusing UI" complaint โ spanning games becomes an intentional, visible state instead of a silent one.
- Submit sends each row's own
gamein thelinespayload. Paste mode is unchanged: still tied to whichever tab is active when "Generate Offer" is clicked.
CardSearchPicker.jsx โ
No changes. Still takes a single game prop and searches one catalog per the active tab โ search stays per-game by design (confirmed direction: don't build a unified cross-game search).
BuylistOrdersTab.jsx โ
- Add an "All" tab, made the default selection.
- Existing per-game tabs now mean "contains at least one line of this game," not "every line is this game."
- Each order row gets game badges, computed client-side from
order.lines(already returned in full by the list endpoint today โ no new API field needed).
BuylistReviewPage.jsx โ
Add the same per-line game badge used in the cart table, so a merchant or customer reviewing an order can see its game composition at a glance.
Edge cases โ
- Game disabled between add-to-cart and submit: explicit 400 naming the disabled game(s), replacing today's silent "[unavailable card]" drop.
- Removing the last line of a given game from an in-progress cart: no special handling โ it's a normal row removal, since
gamelives per-line, not as cart-level state. - Order with lines from a game later disabled entirely for the store: out of scope for this change โ existing orders aren't retroactively invalidated; this matches how disabling a game already behaves for single-game orders today.
Testing โ
- Schema round-trip test for the new line-level
gamefield, per this repo's convention for new persisted fields (CLAUDE.md ยง5.2). - Route tests (
server/routes/api.test.jsor co-located):- Mixed-game
POST /buylist/orderssucceeds and lines carry the right game each. - Mixed-game create with one disabled game among the lines fails with a 400 naming that game; no partial order is created.
GET /buylist/orderswith nogameparam returns orders across games; with agameparam returns exactly the orders containing that game.PATCH /buylist/orders/:idon a mixed-game order reprices each line against its own game's config.
- Mixed-game
- Client tests: switching game tabs preserves existing cart rows; submitted payload tags each row with the game active when it was added.
- Migration script: dry-run against a snapshot of representative production data before running for real; assert zero orders have a line missing
gameafterward.
