Skip to content

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 rawText or lines, 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:

  • buylistLineSchema gains a required field: game: { type: String, required: true, index: true }. No default โ€” matches this repo's existing rule that game is never defaulted (CLAUDE.md ยง5.5).
  • The order-level game field 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/:

  1. For every existing BuylistOrder, copy order.game onto every line in order.lines.
  2. Unset the order-level game field.
  3. Provide a --dry-run mode 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 own game, validated via isGameSupported. The route:
    1. Groups incoming lines by distinct game.
    2. For each distinct game, calls assertBuylistEnabled against that game's config. If any game is disabled/unsupported, return one 400 that names every offending game โ€” no silent partial acceptance.
    3. Calls priceSelectedLines per game group (existing per-game pricing logic, unchanged internally), then merges the results back into one lines array in the original submitted order.
    4. Saves the order without a top-level game.
  • rawText path (paste import, out of scope for spanning): unchanged behavior. game moves from being a bare query param to a required body field for this path only, for schema consistency now that lines no longer carries an implicit order-level game via the query string. Every line generateQuote produces 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 โ€‹

  • game query 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's game onto the new cart row at add-time.
  • setGame (game tab click) no longer clears selectedCards โ€” 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 game in the lines payload. 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 game lives 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 game field, per this repo's convention for new persisted fields (CLAUDE.md ยง5.2).
  • Route tests (server/routes/api.test.js or co-located):
    • Mixed-game POST /buylist/orders succeeds 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/orders with no game param returns orders across games; with a game param returns exactly the orders containing that game.
    • PATCH /buylist/orders/:id on a mixed-game order reprices each line against its own game's config.
  • 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 game afterward.