Appearance
Cross-Game Buylist Orders 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: Let one buylist (buy) order legitimately contain lines from multiple games, in both the merchant "New Buy-In" flow and the public customer portal, instead of the current single-game-per-order model that a client-side cart bug already silently violates.
Architecture: Move game from a required field on BuylistOrder down to a required field on each BuylistOrder line. Every place that currently reads/writes one order-level game (create routes, list filter, reprice route, the public portal's parallel service, and both selector UIs) is updated to work per-line instead. A new resolveLineGameConfigs(store, lines) helper in buylistConfigService.js centralizes "which games are in this line set, are they all supported and enabled" so the merchant route and the two public-portal service functions share one tested implementation instead of three copies.
Tech Stack: Node/Express, Mongoose, Zod, Vitest (server); React 18, Vitest + Testing Library (client). No new dependencies.
Global Constraints โ
gameis never defaulted anywhere in this codebase (CLAUDE.md ยง5.5) โ every newgamefield isrequired, with no fallback value. Grep your diff for|| 'mtg'before calling a task done.- Every new persisted field needs a schema-shape round-trip test (CLAUDE.md ยง5.2) โ Task 1 does this for
buylistLineSchema.game. - Out of scope (confirmed in the design spec,
docs/superpowers/specs/2026-07-20-cross-game-buylist-orders-design.md): the "Paste List" import mode stays single-game per submission. Only the search-and-select cart flow becomes cross-game-capable. server/routes/api.jshas no dedicated route-level test file today (onlypublicBuylist.test.jsandwebhooks.test.jsexist underserver/routes/). This plan follows the existing pattern for that file: route handlers stay thin orchestration over already-unit-tested service/schema functions, so route changes in Tasks 8โ10 are verified via the service/schema tests in Tasks 3โ7 plus a manual dev-server smoke check, not a new route test harness.- Run
npm test(server) after every task andnpm run test:clientafter every client task (Tasks 12โ15). Runnpm run lintbefore each commit.
Task 1: BuylistOrder model โ per-line game, remove order-level game โ
Files:
- Modify:
server/models/BuylistOrder.js - Test:
server/models/BuylistOrder.test.js(new file โ no existing test file for this model)
Interfaces:
Produces:
buylistLineSchemanow requiresgame: Stringon every line.buylistOrderSchemano longer has a top-levelgamefield. Every later task that builds or reads aBuylistOrder/line relies on this.[ ] Step 1: Write the failing round-trip test
This repo has no mongodb-memory-server dependency and no live-Mongo model tests โ server/models/SealedProduct.test.js (the round-trip example server/CLAUDE.md ยง5.2 points to) tests entirely against an in-memory Mongoose document via new Model(data) + .validateSync()/.toObject(), no database connection at all. Mirror that exact pattern:
js
// server/models/BuylistOrder.test.js
import { describe, it, expect, beforeEach } from 'vitest';
let BuylistOrder;
beforeEach(async () => {
const module = await import('./BuylistOrder.js');
BuylistOrder = module.default;
});
describe('BuylistOrder line-level game field', () => {
it('round-trips game on each line through construction and serialization', () => {
const order = new BuylistOrder({
shop: 'test.myshopify.com',
source: 'merchant',
customer: { email: 'a@b.com' },
lines: [
{ rawLine: '1 Bolt [CLB] 187', matchStatus: 'matched', game: 'mtg', quantity: 1 },
{ rawLine: '1 Pikachu', matchStatus: 'matched', game: 'pokemon', quantity: 1 }
],
payout: { method: 'store_credit' }
});
expect(order.validateSync()).toBeUndefined();
const plain = order.toObject();
expect(plain.lines[0].game).toBe('mtg');
expect(plain.lines[1].game).toBe('pokemon');
expect(plain.game).toBeUndefined();
});
it('rejects a line with no game', () => {
const order = new BuylistOrder({
shop: 'test.myshopify.com',
source: 'merchant',
customer: { email: 'a@b.com' },
lines: [{ rawLine: '1 Bolt [CLB] 187', matchStatus: 'matched', quantity: 1 }],
payout: { method: 'store_credit' }
});
const error = order.validateSync();
expect(error).toBeDefined();
expect(error.errors['lines.0.game']).toBeDefined();
});
});- [ ] Step 2: Run test to verify it fails
Run: npx vitest run server/models/BuylistOrder.test.js Expected: FAIL โ game doesn't exist on buylistLineSchema yet, so the first test's plain.lines[0].game assertion fails, and the second test's error.errors['lines.0.game'] is undefined (no required-field validation exists on the line yet).
- [ ] Step 3: Update the model
Edit server/models/BuylistOrder.js:
js
const buylistLineSchema = new mongoose.Schema({
rawLine: { type: String, required: true },
matchStatus: { type: String, enum: ['matched', 'unmatched'], required: true },
// Never defaulted โ mirrors the order-level rule this field replaces (CLAUDE.md ยง5.5).
game: { type: String, required: true, index: true },
cardUuid: String,
cardName: String,
setCode: String,
collectorNumber: String,
finish: String,
condition: { type: String, enum: ['nm', 'lp', 'mp', 'hp', 'damaged'], default: 'nm' },
quantity: { type: Number, required: true, min: 1 },
basisPrice: Number,
ruleApplied: String,
offerPrice: Number,
included: { type: Boolean, default: true }
}, { _id: false });
const buylistOrderSchema = new mongoose.Schema({
shop: { type: String, required: true, index: true },
source: { type: String, enum: ['merchant', 'kiosk', 'storefront'], required: true },
status: {
type: String,
enum: ['pending', 'offer_sent', 'accepted', 'declined', 'paid'],
default: 'pending',
index: true
},
customer: {
email: { type: String, required: true },
name: String
},
claimTokenHash: String,
kioskDeviceId: String,
lines: { type: [buylistLineSchema], default: [] },
payout: {
method: { type: String, enum: ['store_credit', 'cash'], default: 'store_credit' },
cashTotal: Number,
creditTotal: Number,
creditBonus: Number,
marketTotal: Number
}
}, { timestamps: true, collection: 'buylist_orders' });
buylistOrderSchema.index({ shop: 1, status: 1, createdAt: -1 });
buylistOrderSchema.index({ shop: 1, 'lines.game': 1 });
module.exports = mongoose.model('BuylistOrder', buylistOrderSchema);(Only the game field moved from the order schema to the line schema, and the order-level {shop,status,createdAt} index gained a sibling {shop,'lines.game'} index โ everything else is unchanged.)
- [ ] Step 4: Run test to verify it passes
Run: npx vitest run server/models/BuylistOrder.test.js Expected: PASS (2 tests)
- [ ] Step 5: Commit
bash
git add server/models/BuylistOrder.js server/models/BuylistOrder.test.js
git commit -m "Move buylist order game from order-level to per-line"Task 2: Migration script for existing orders โ
Files:
- Create:
server/scripts/migrations/backfillBuylistOrderLineGame.js - Test:
server/scripts/migrations/backfillBuylistOrderLineGame.test.js - Modify:
package.json(two new scripts)
Interfaces:
Produces:
backfillLineGame(order)โ pure function,{ order: { game, lines } }โ{ changed: boolean, lines: [...] }, mirroring the existingmigrateStorePricingConfigpattern inmigratePerGamePricingConfig.js.[ ] Step 1: Write the failing test for the pure transform
js
// server/scripts/migrations/backfillBuylistOrderLineGame.test.js
import { describe, it, expect } from 'vitest';
import { backfillLineGame } from './backfillBuylistOrderLineGame.js';
describe('backfillLineGame', () => {
it('copies the order-level game onto every line missing one', () => {
const order = { game: 'mtg', lines: [{ cardUuid: 'a' }, { cardUuid: 'b', game: undefined }] };
const { changed, lines } = backfillLineGame(order);
expect(changed).toBe(true);
expect(lines[0].game).toBe('mtg');
expect(lines[1].game).toBe('mtg');
});
it('is idempotent: an order whose lines already all have game is untouched', () => {
const order = { game: 'mtg', lines: [{ cardUuid: 'a', game: 'mtg' }] };
const { changed } = backfillLineGame(order);
expect(changed).toBe(false);
});
it('is a no-op for an order with no game and no lines needing one (already migrated / already unset)', () => {
const order = { lines: [{ cardUuid: 'a', game: 'pokemon' }] };
const { changed } = backfillLineGame(order);
expect(changed).toBe(false);
});
});- [ ] Step 2: Run test to verify it fails
Run: npx vitest run server/scripts/migrations/backfillBuylistOrderLineGame.test.js Expected: FAIL โ module doesn't exist yet.
- [ ] Step 3: Write the migration script
js
// server/scripts/migrations/backfillBuylistOrderLineGame.js
/**
* Migration: copy BuylistOrder.game onto every line, then unset the
* order-level field, ahead of removing it from the schema (Task 1).
*
* Run with:
* node server/scripts/migrations/backfillBuylistOrderLineGame.js [--dry-run]
*
* Idempotent: orders whose lines already all carry game are skipped.
*/
require('dotenv').config();
const mongoose = require('mongoose');
function backfillLineGame(order) {
if (!order.game) return { changed: false, lines: order.lines };
let changed = false;
const lines = order.lines.map((line) => {
if (line.game) return line;
changed = true;
return { ...line, game: order.game };
});
return { changed, lines };
}
async function run() {
const dryRun = process.argv.includes('--dry-run');
// Read via the raw driver, not the Mongoose model โ by the time this
// script ships, BuylistOrder's schema no longer declares `game` (Task 1),
// and Mongoose would silently drop it from query results even though the
// field still exists in already-written documents.
const logger = require('../../utils/logger');
const mongoUri = process.env.MONGODB_URI;
if (!mongoUri) throw new Error('MONGODB_URI not configured');
await mongoose.connect(mongoUri);
logger.info('Connected to MongoDB', { dryRun });
const collection = mongoose.connection.collection('buylist_orders');
const cursor = collection.find({ game: { $exists: true } });
let migrated = 0;
let skipped = 0;
for await (const order of cursor) {
const { changed, lines } = backfillLineGame(order);
if (!changed) { skipped++; continue; }
logger.info(`${dryRun ? '[dry-run] Would migrate' : 'Migrating'} order`, { orderId: order._id, game: order.game });
if (!dryRun) {
await collection.updateOne(
{ _id: order._id },
{ $set: { lines }, $unset: { game: '' } }
);
}
migrated++;
}
logger.info('Migration complete', { migrated, skipped, dryRun });
await mongoose.disconnect();
}
module.exports = { backfillLineGame };
if (require.main === module) {
run().then(() => process.exit(0)).catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});
}- [ ] Step 4: Run test to verify it passes
Run: npx vitest run server/scripts/migrations/backfillBuylistOrderLineGame.test.js Expected: PASS (3 tests)
- [ ] Step 5: Add npm scripts
In package.json, alongside the existing migrate:pricing-config pair:
json
"migrate:buylist-line-game": "node server/scripts/migrations/backfillBuylistOrderLineGame.js",
"migrate:buylist-line-game:dry-run": "node server/scripts/migrations/backfillBuylistOrderLineGame.js --dry-run",- [ ] Step 6: Commit
bash
git add server/scripts/migrations/backfillBuylistOrderLineGame.js server/scripts/migrations/backfillBuylistOrderLineGame.test.js package.json
git commit -m "Add migration to backfill per-line game on existing buylist orders"Note for whoever deploys this: run npm run migrate:buylist-line-game:dry-run against production first, confirm the migrated count matches expectations, then run npm run migrate:buylist-line-game for real, before or alongside deploying the rest of this plan's server changes.
Task 3: buylistConfigService.js โ add resolveLineGameConfigs โ
Files:
- Modify:
server/services/buylistConfigService.js - Modify:
server/services/buylistConfigService.test.js
Interfaces:
Consumes:
getGameBuylistConfig(store, gameId),assertBuylistEnabled(config)(both already in this file),getSupportedGames()from../plugins.Produces:
resolveLineGameConfigs(store, lines)โ{ error: string, status: 400 } | { configsByGame: { [game]: BuylistConfig } }.linesis any array of objects with a.gamestring field. Tasks 8 and 10 both call this.[ ] Step 1: Write the failing tests
Add to server/services/buylistConfigService.test.js:
js
describe('resolveLineGameConfigs', () => {
it('returns configsByGame keyed by every distinct game in the lines', () => {
const store = fakeStore({ mtg: { enabled: true }, pokemon: { enabled: true } });
const result = resolveLineGameConfigs(store, [{ game: 'mtg' }, { game: 'pokemon' }, { game: 'mtg' }]);
expect(result.error).toBeUndefined();
expect(Object.keys(result.configsByGame).sort()).toEqual(['mtg', 'pokemon']);
expect(result.configsByGame.mtg.enabled).toBe(true);
});
it('returns a 400-shaped error naming every unsupported game', () => {
const store = fakeStore();
const result = resolveLineGameConfigs(store, [{ game: 'mtg' }, { game: 'yugioh' }]);
expect(result).toEqual({ error: 'Unsupported game(s): yugioh', status: 400 });
});
it('returns a 400-shaped error naming every disabled game, without also flagging unsupported ones', () => {
const store = fakeStore({ mtg: { enabled: false }, pokemon: { enabled: true } });
const result = resolveLineGameConfigs(store, [{ game: 'mtg' }, { game: 'pokemon' }]);
expect(result).toEqual({ error: 'Buylist is not enabled for: mtg', status: 400 });
});
});- [ ] Step 2: Run tests to verify they fail
Run: npx vitest run server/services/buylistConfigService.test.js Expected: FAIL โ resolveLineGameConfigs is not exported.
- [ ] Step 3: Implement it
Edit server/services/buylistConfigService.js โ add getSupportedGames to the existing require('../plugins') import, and add the new function above module.exports:
js
const { getPlugin, getSupportedGames } = require('../plugins');js
/**
* Validates every distinct game present in a set of buylist lines (each line
* an object with a .game field) and resolves each one's buylist config in a
* single pass. Shared by the merchant create route and both public-portal
* quote/create paths so "is this line set sellable" is defined once.
*/
function resolveLineGameConfigs(store, lines) {
const distinctGames = [...new Set(lines.map((l) => l.game))];
const unsupported = distinctGames.filter((g) => !getSupportedGames().includes(g));
if (unsupported.length) {
return { error: `Unsupported game(s): ${unsupported.join(', ')}`, status: 400 };
}
const configsByGame = {};
const disabled = [];
for (const game of distinctGames) {
const config = getGameBuylistConfig(store, game);
configsByGame[game] = config;
if (assertBuylistEnabled(config)) disabled.push(game);
}
if (disabled.length) {
return { error: `Buylist is not enabled for: ${disabled.join(', ')}`, status: 400 };
}
return { configsByGame };
}Add resolveLineGameConfigs to module.exports.
- [ ] Step 4: Run tests to verify they pass
Run: npx vitest run server/services/buylistConfigService.test.js Expected: PASS (all existing tests plus the 3 new ones)
- [ ] Step 5: Commit
bash
git add server/services/buylistConfigService.js server/services/buylistConfigService.test.js
git commit -m "Add resolveLineGameConfigs for validating multi-game buylist line sets"Task 4: buylistQuoteService.js โ per-line game through pricing and totals โ
Files:
- Modify:
server/services/buylistQuoteService.js - Modify:
server/services/buylistQuoteService.test.js
Interfaces:
Consumes:
resolveLineGameConfigsis NOT used here โ this file stays store-config-agnostic except viadeps.getGameBuylistConfig, matching its existing dependency-injection pattern.Produces:
priceIdentifiedLine/priceAndRateLinenow includegamein every returned line.computeOrderTotals(lines, buylistConfigsByGame)โ signature change: second argument is now a map keyed by game, not a single config.priceSelectedLines({ store, lines })โ signature change: no top-levelgame; every line inlinesmust carry its owngame.[ ] Step 1: Update existing tests for the new shapes (write these as failing first)
In server/services/buylistQuoteService.test.js:
a) priceIdentifiedLine describe block โ add game: 'mtg' to the three matched-line toEqual expectations (lines ~752, and the two others in that block), and add game: 'mtg' as an argument position no โ game is passed into the function already; only the returned object literals need the new field:
js
// In 'prices a selected card by its requested finish...':
expect(line).toEqual({
rawLine: '1 Ragavan, Nimble Pilferer [MH2] 138',
matchStatus: 'matched',
game: 'mtg',
cardUuid: 'uuid-1',
cardName: 'Ragavan, Nimble Pilferer',
setCode: 'MH2',
collectorNumber: '138',
finish: 'Foil',
condition: 'nm',
quantity: 1,
basisPrice: 48.50,
ruleApplied: 'default',
offerPrice: 19.40,
included: true
});js
// In 'returns an unmatched line when the card no longer exists':
expect(line).toEqual({
rawLine: '3 [unavailable card stale-uuid]',
matchStatus: 'unmatched',
game: 'mtg',
quantity: 3,
condition: 'lp',
included: false
});(The finish-fallback test and the ignores-conflicting-fields test only assert on specific fields, not toEqual the whole object, so they don't need changes.)
b) computeOrderTotals describe block โ change the second argument to a map and add game to each line fixture:
js
describe('computeOrderTotals', () => {
it('sums only included, matched lines and applies each line\'s own game\'s store credit bonus', () => {
const lines = [
{ matchStatus: 'matched', included: true, game: 'mtg', offerPrice: 24.25, basisPrice: 48.50, quantity: 1 },
{ matchStatus: 'matched', included: true, game: 'mtg', offerPrice: 0.80, basisPrice: 1.60, quantity: 3 },
{ matchStatus: 'matched', included: false, game: 'mtg', offerPrice: 100, basisPrice: 200, quantity: 1 },
{ matchStatus: 'unmatched', included: false, game: 'mtg', offerPrice: 0, quantity: 1 }
];
const totals = computeOrderTotals(lines, { mtg: { storeCreditBonus: 0.25 } });
expect(totals.cashTotal).toBe(26.65);
expect(totals.creditBonus).toBe(0.25);
expect(totals.creditTotal).toBe(33.31);
expect(totals.marketTotal).toBe(53.30);
});
it('treats a matched-but-unpriced line (basisPrice null) as a zero market contribution', () => {
const lines = [
{ matchStatus: 'matched', included: true, game: 'mtg', offerPrice: 0, basisPrice: null, quantity: 2 },
{ matchStatus: 'matched', included: true, game: 'mtg', offerPrice: 10, basisPrice: 20, quantity: 1 }
];
const totals = computeOrderTotals(lines, { mtg: { storeCreditBonus: 0 } });
expect(totals.marketTotal).toBe(20);
});
it('blends per-game store credit bonus rates across a mixed-game order', () => {
const lines = [
{ matchStatus: 'matched', included: true, game: 'mtg', offerPrice: 10, basisPrice: 20, quantity: 1 },
{ matchStatus: 'matched', included: true, game: 'pokemon', offerPrice: 10, basisPrice: 20, quantity: 1 }
];
// mtg pays 25% bonus, pokemon pays 50% -- cash is 20, credit is
// (10*1.25 + 10*1.50) = 27.50, so the blended rate is 0.375, not
// either game's own rate.
const totals = computeOrderTotals(lines, { mtg: { storeCreditBonus: 0.25 }, pokemon: { storeCreditBonus: 0.50 } });
expect(totals.cashTotal).toBe(20);
expect(totals.creditTotal).toBe(27.50);
expect(totals.creditBonus).toBe(0.375);
});
});c) generateQuote describe block โ add game: 'mtg' to the two toEqual line assertions (the 'composes parse -> match -> price -> rate' test and the 'marks an unmatched line' test), same pattern as (a).
d) priceSelectedLines describe block โ replace the call with the new no-top-level-game shape and a line that carries its own game:
js
describe('priceSelectedLines', () => {
afterEach(() => _resetDeps());
it('prices every selected line using its own game\'s config', async () => {
const store = { buylistConfig: {} };
const getGameBuylistConfig = vi.fn().mockReturnValue({ defaultRate: 0.40, priceBasis: 'market' });
_setDeps({
getGameBuylistConfig,
priceIdentifiedLine: async (selected, game) => ({
rawLine: `${selected.quantity} Test Card [TST] 1`,
matchStatus: 'matched',
game,
cardUuid: selected.cardUuid,
cardName: 'Test Card',
setCode: 'TST',
collectorNumber: '1',
finish: 'Nonfoil',
condition: selected.condition,
quantity: selected.quantity,
basisPrice: 10,
ruleApplied: 'default',
offerPrice: 4,
included: true
})
});
const result = await priceSelectedLines({
store,
lines: [{ cardUuid: 'uuid-1', game: 'mtg', finish: 'Nonfoil', condition: 'nm', quantity: 2 }]
});
expect(result.lines).toHaveLength(1);
expect(result.lines[0].game).toBe('mtg');
expect(result.lines[0].offerPrice).toBe(4);
});
it('resolves each distinct game\'s config only once even when lines repeat a game', async () => {
const getGameBuylistConfig = vi.fn().mockReturnValue({ defaultRate: 0.40, priceBasis: 'market' });
_setDeps({
getGameBuylistConfig,
priceIdentifiedLine: async (selected, game) => ({ matchStatus: 'matched', game, cardUuid: selected.cardUuid, included: true })
});
await priceSelectedLines({
store: {},
lines: [
{ cardUuid: 'uuid-1', game: 'mtg', finish: '', condition: 'nm', quantity: 1 },
{ cardUuid: 'uuid-2', game: 'mtg', finish: '', condition: 'nm', quantity: 1 },
{ cardUuid: 'uuid-3', game: 'pokemon', finish: '', condition: 'nm', quantity: 1 }
]
});
expect(getGameBuylistConfig).toHaveBeenCalledTimes(2);
});
});- [ ] Step 2: Run tests to verify they fail
Run: npx vitest run server/services/buylistQuoteService.test.js Expected: FAIL โ implementation doesn't include game in returned lines yet, and computeOrderTotals/priceSelectedLines don't accept the new shapes.
- [ ] Step 3: Implement the changes
In server/services/buylistQuoteService.js:
priceAndRateLine โ add game to both return branches:
js
async function priceAndRateLine(parsed, game, buylistConfig, deps) {
const match = await deps.matchCatalogLine(parsed, game, deps);
if (match.matchStatus === 'unmatched') {
return {
rawLine: parsed.rawLine,
matchStatus: 'unmatched',
game,
quantity: parsed.quantity,
condition: parsed.condition,
included: false
};
}
const basisPrice = await deps.resolveBasisPrice(
{ game, cardUuid: match.cardUuid, rarity: match.rarity, finish: match.finish },
buylistConfig.priceBasis,
deps
);
const { offerPrice, ruleApplied } = deps.applyBuylistRate(
{ basisPrice, rarity: match.rarity, condition: parsed.condition, quantity: parsed.quantity },
buylistConfig
);
return {
rawLine: parsed.rawLine,
matchStatus: 'matched',
game,
cardUuid: match.cardUuid,
cardName: match.cardName,
setCode: match.setCode,
collectorNumber: match.collectorNumber,
finish: match.finish,
condition: parsed.condition,
quantity: parsed.quantity,
basisPrice,
ruleApplied,
offerPrice,
included: true
};
}priceIdentifiedLine โ add game to both return branches (unmatched branch, then the matched branch):
js
async function priceIdentifiedLine(selected, game, buylistConfig, deps) {
const card = await deps.findCardByUuid(selected.cardUuid, game, deps);
if (!card) {
return {
rawLine: `${selected.quantity} [unavailable card ${selected.cardUuid}]`,
matchStatus: 'unmatched',
game,
quantity: selected.quantity,
condition: selected.condition,
included: false
};
}
const finish = card.variants.some((v) => v.finish === selected.finish)
? selected.finish
: card.variants[0].finish;
const basisPrice = await deps.resolveBasisPrice(
{ game, cardUuid: card.cardUuid, rarity: card.rarity, finish },
buylistConfig.priceBasis,
deps
);
const { offerPrice, ruleApplied } = deps.applyBuylistRate(
{ basisPrice, rarity: card.rarity, condition: selected.condition, quantity: selected.quantity },
buylistConfig
);
return {
rawLine: `${selected.quantity} ${card.cardName} [${card.setCode}] ${card.collectorNumber}`,
matchStatus: 'matched',
game,
cardUuid: card.cardUuid,
cardName: card.cardName,
setCode: card.setCode,
collectorNumber: card.collectorNumber,
finish,
condition: selected.condition,
quantity: selected.quantity,
basisPrice,
ruleApplied,
offerPrice,
included: true
};
}computeOrderTotals โ accept a map, blend the credit bonus:
js
/**
* buylistConfigsByGame: { [game]: BuylistConfig }, one entry per distinct
* game present in `lines`. creditBonus in the return value is the blended
* rate implied by cashTotal -> creditTotal (so payout.creditBonus stays a
* single stored number even for a mixed-game order); it equals the single
* game's own storeCreditBonus exactly when every line shares one game.
*/
function computeOrderTotals(lines, buylistConfigsByGame) {
const includedLines = lines.filter((l) => l.included && l.matchStatus === 'matched');
const cashTotal = round2(includedLines.reduce((sum, l) => sum + l.offerPrice * l.quantity, 0));
const creditTotal = round2(includedLines.reduce((sum, l) => {
const bonus = buylistConfigsByGame[l.game]?.storeCreditBonus ?? 0;
return sum + l.offerPrice * l.quantity * (1 + bonus);
}, 0));
const creditBonus = cashTotal > 0 ? round2((creditTotal - cashTotal) / cashTotal) : 0;
const marketTotal = round2(includedLines.reduce((sum, l) => sum + (l.basisPrice ?? 0) * l.quantity, 0));
return { cashTotal, creditTotal, creditBonus, marketTotal };
}priceSelectedLines โ group by each line's own game, resolving (and caching) config per distinct game:
js
/**
* Top-level composition for the selector path. Unlike generateQuote (one
* game for the whole pasted batch), every selected line carries its own
* game, so each distinct game's buylistConfig is resolved once and reused
* across that game's lines.
*/
async function priceSelectedLines({ store, lines }, deps = getDeps()) {
const configByGame = new Map();
const getConfig = (game) => {
if (!configByGame.has(game)) configByGame.set(game, deps.getGameBuylistConfig(store, game));
return configByGame.get(game);
};
const results = [];
for (let i = 0; i < lines.length; i += GENERATE_QUOTE_BATCH_SIZE) {
const batch = lines.slice(i, i + GENERATE_QUOTE_BATCH_SIZE);
const batchResults = await Promise.all(
batch.map((selected) => deps.priceIdentifiedLine(selected, selected.game, getConfig(selected.game), deps))
);
results.push(...batchResults);
}
return { lines: results };
}- [ ] Step 4: Run tests to verify they pass
Run: npx vitest run server/services/buylistQuoteService.test.js Expected: PASS (all existing tests, updated, plus the new mixed-game tests)
- [ ] Step 5: Commit
bash
git add server/services/buylistQuoteService.js server/services/buylistQuoteService.test.js
git commit -m "Thread per-line game through buylist pricing and blend credit bonus across games"Task 5: schemas/buylistOrder.js โ per-line game, restructure top-level game โ
Files:
- Modify:
server/schemas/buylistOrder.js - Modify:
server/schemas/schemas.test.js
Interfaces:
Produces:
selectedCardLineSchemagains requiredgame: z.string().min(1).createBuylistOrderSchemagains an optional top-levelgame, required exactly whenrawTextis present and forbidden whenlinesis present.[ ] Step 1: Write the failing schema tests
In server/schemas/schemas.test.js, within describe('createBuylistOrderSchema', ...), add:
js
it('rejects a lines entry missing game', () => {
const result = createBuylistOrderSchema.safeParse({
customer: { email: 'jamie@example.com' },
lines: [{ cardUuid: 'uuid-1', finish: 'Nonfoil', condition: 'nm', quantity: 1 }]
});
expect(result.success).toBe(false);
});
it('accepts a lines entry with game', () => {
const result = createBuylistOrderSchema.safeParse({
customer: { email: 'jamie@example.com' },
lines: [{ cardUuid: 'uuid-1', game: 'mtg', finish: 'Nonfoil', condition: 'nm', quantity: 1 }]
});
expect(result.success).toBe(true);
});
it('rejects rawText without a top-level game', () => {
const result = createBuylistOrderSchema.safeParse({
customer: { email: 'a@b.com' },
rawText: '1 Lightning Bolt [CLB] 187'
});
expect(result.success).toBe(false);
});
it('accepts rawText with a top-level game', () => {
const result = createBuylistOrderSchema.safeParse({
customer: { email: 'a@b.com' },
game: 'mtg',
rawText: '1 Lightning Bolt [CLB] 187'
});
expect(result.success).toBe(true);
});
it('rejects a top-level game supplied alongside lines (ambiguous โ each line already has its own)', () => {
const result = createBuylistOrderSchema.safeParse({
customer: { email: 'jamie@example.com' },
game: 'mtg',
lines: [{ cardUuid: 'uuid-1', game: 'mtg', finish: 'Nonfoil', condition: 'nm', quantity: 1 }]
});
expect(result.success).toBe(false);
});Every existing test in this describe block that passes rawText without game ('accepts a valid create body', 'accepts an optional payoutMethod', 'rejects a missing customer email', 'rejects an invalid email', 'rejects blank rawText', 'rejects rawText over 65536 characters', 'accepts rawText at exactly the 65536 character limit', 'rejects an invalid payoutMethod', 'defaults format to plain when omitted', 'accepts format: manabox', 'rejects an unsupported format value') needs game: 'mtg' added to its input object โ update each one now, in the same edit, so Step 2 shows the real failure set instead of only the five new tests. Every existing lines-based test needs game: 'mtg' (or per-line, for the 501-line test just repeat 'mtg') added to each line object, for the same reason.
- [ ] Step 2: Run tests to verify they fail
Run: npx vitest run server/schemas/schemas.test.js -t createBuylistOrderSchema Expected: FAIL โ game isn't a recognized field on either schema yet, so both the new tests and every updated existing test fail against the current implementation.
- [ ] Step 3: Implement the schema changes
Edit server/schemas/buylistOrder.js:
js
const selectedCardLineSchema = z.object({
cardUuid: z.string().min(1, 'cardUuid is required'),
game: z.string().min(1, 'game is required'),
finish: z.string(),
condition: z.enum(VALID_CONDITIONS),
quantity: z.number().int().min(1).max(9999)
}).strict();
const createBuylistOrderSchema = z.object({
customer: z.object({
email: z.string().email('customer.email must be a valid email'),
name: z.string().optional()
}),
// Required only alongside rawText (one game per pasted batch โ see the
// design spec's scope note on why paste mode stays single-game).
// Forbidden alongside lines, where each line already carries its own.
game: z.string().min(1).optional(),
rawText: z.string().trim().min(1, 'rawText must not be blank').max(65536, 'rawText is too long').optional(),
format: z.enum(['plain', 'manabox', 'tcgplayer']).optional().default('plain'),
lines: z.array(selectedCardLineSchema).min(1, 'lines must not be empty').max(500, 'too many lines').optional(),
payoutMethod: z.enum(['store_credit', 'cash']).optional()
}).strict().refine(
(data) => (data.rawText != null) !== (data.lines != null),
{ message: 'Provide either rawText or lines, but not both' }
).refine(
(data) => data.rawText == null || data.game != null,
{ message: 'game is required when rawText is provided' }
).refine(
(data) => data.lines == null || data.game == null,
{ message: 'game must not be provided alongside lines โ each line carries its own' }
);updateBuylistOrderLinesSchema and the module exports are unchanged.
- [ ] Step 4: Run tests to verify they pass
Run: npx vitest run server/schemas/schemas.test.js -t createBuylistOrderSchema Expected: PASS
- [ ] Step 5: Run the full schema test file
Run: npx vitest run server/schemas/schemas.test.js Expected: PASS (nothing else in this file references selectedCardLineSchema or createBuylistOrderSchema outside what was just updated)
- [ ] Step 6: Commit
bash
git add server/schemas/buylistOrder.js server/schemas/schemas.test.js
git commit -m "Require game per buylist order line, restrict top-level game to the rawText path"Task 6: schemas/publicBuylist.js โ drop top-level game โ
Files:
- Modify:
server/schemas/publicBuylist.js - Modify:
server/schemas/schemas.test.js
Interfaces:
Produces:
publicQuoteSchemaandpublicOrderCreateSchemano longer have a top-levelgamefield โpublicLinesSchema(built fromselectedCardLineSchema, Task 5) already requiresgameper line.[ ] Step 1: Update the failing tests
In server/schemas/schemas.test.js, describe('publicQuoteSchema', ...): every validLine needs game: 'mtg', and every { game: 'mtg', lines: ... } call site drops the top-level game: 'mtg':
js
describe('publicQuoteSchema', () => {
const validLine = { cardUuid: 'card-1', game: 'mtg', finish: 'normal', condition: 'nm', quantity: 1 };
it('accepts a valid quote request', () => {
expect(publicQuoteSchema.safeParse({ lines: [validLine] }).success).toBe(true);
});
it('rejects an empty lines array', () => {
expect(publicQuoteSchema.safeParse({ lines: [] }).success).toBe(false);
});
it('rejects more than 200 lines', () => {
const lines = Array.from({ length: 201 }, () => validLine);
expect(publicQuoteSchema.safeParse({ lines }).success).toBe(false);
});
it('rejects a rawText field โ the public surface is selector-only', () => {
expect(publicQuoteSchema.safeParse({ lines: [validLine], rawText: '1 Bolt' }).success).toBe(false);
});
it('rejects a lines entry missing game', () => {
const { game: _game, ...lineWithoutGame } = validLine;
expect(publicQuoteSchema.safeParse({ lines: [lineWithoutGame] }).success).toBe(false);
});
});Same pattern for describe('publicOrderCreateSchema', ...):
js
describe('publicOrderCreateSchema', () => {
const validLine = { cardUuid: 'card-1', game: 'mtg', finish: 'normal', condition: 'nm', quantity: 1 };
it('accepts a valid order-create request', () => {
expect(publicOrderCreateSchema.safeParse({
lines: [validLine],
customer: { email: 'customer@example.com', name: 'Sam' }
}).success).toBe(true);
});
it('accepts a customer with no name', () => {
expect(publicOrderCreateSchema.safeParse({
lines: [validLine],
customer: { email: 'customer@example.com' }
}).success).toBe(true);
});
it('rejects an invalid customer email', () => {
expect(publicOrderCreateSchema.safeParse({
lines: [validLine],
customer: { email: 'not-an-email' }
}).success).toBe(false);
});
it('rejects a missing customer', () => {
expect(publicOrderCreateSchema.safeParse({ lines: [validLine] }).success).toBe(false);
});
it('accepts lines spanning multiple games', () => {
expect(publicOrderCreateSchema.safeParse({
lines: [validLine, { ...validLine, cardUuid: 'card-2', game: 'pokemon' }],
customer: { email: 'customer@example.com' }
}).success).toBe(true);
});
});(The 'rejects a missing game' test in the old publicQuoteSchema block is removed โ there's no longer a top-level game to be missing.)
- [ ] Step 2: Run tests to verify they fail
Run: npx vitest run server/schemas/schemas.test.js -t publicQuoteSchema Run: npx vitest run server/schemas/schemas.test.js -t publicOrderCreateSchema Expected: FAIL โ the schemas still require a top-level game.
- [ ] Step 3: Implement the schema changes
Edit server/schemas/publicBuylist.js โ drop the game: requiredGameSchema line from both schemas, and drop the now-unused requiredGameSchema import:
js
const { objectIdSchema } = require('./params');
const { selectedCardLineSchema } = require('./buylistOrder');js
const publicQuoteSchema = z.object({
lines: publicLinesSchema
}).strict();
const publicOrderCreateSchema = z.object({
lines: publicLinesSchema,
customer: z.object({
email: z.string().email('customer.email must be a valid email'),
name: z.string().optional()
})
}).strict();- [ ] Step 4: Run tests to verify they pass
Run: npx vitest run server/schemas/schemas.test.js Expected: PASS
- [ ] Step 5: Commit
bash
git add server/schemas/publicBuylist.js server/schemas/schemas.test.js
git commit -m "Drop top-level game from public buylist schemas now that lines carry their own"Task 7: publicBuylistService.js โ multi-game quote and create โ
Files:
- Modify:
server/services/publicBuylistService.js - Modify:
server/services/publicBuylistService.test.js - Modify:
server/routes/publicBuylist.js(handler call sites)
Interfaces:
Consumes:
resolveLineGameConfigs(Task 3),priceSelectedLines/computeOrderTotalsnew signatures (Task 4).Produces:
quotePublicLines(store, lines)andcreatePublicOrder(store, lines, customer)โ both drop thegameparameter.toPublicOrderno longer returnsgame.[ ] Step 1: Update the failing tests
In server/services/publicBuylistService.test.js, describe('quotePublicLines', ...):
js
describe('quotePublicLines', () => {
const store = { shop: 'test.myshopify.com' };
const lines = [{ cardUuid: 'card-1', game: 'mtg', finish: 'normal', condition: 'nm', quantity: 1 }];
it('returns a 400-shaped error for an unsupported game', async () => {
const deps = makeDeps();
_setDeps(deps);
const result = await quotePublicLines(store, [{ cardUuid: 'card-1', game: 'yugioh', finish: 'normal', condition: 'nm', quantity: 1 }]);
expect(result).toEqual({ error: 'Unsupported game(s): yugioh', status: 400 });
expect(deps.getGameBuylistConfig).not.toHaveBeenCalled();
});
it('returns the disabled-error shape when buylist is disabled for the game', async () => {
const deps = makeDeps();
deps.getGameBuylistConfig.mockReturnValue({ enabled: false, instantQuote: { web: true } });
deps.assertBuylistEnabled.mockReturnValue('Buylist is not enabled for this game');
_setDeps(deps);
const result = await quotePublicLines(store, lines);
expect(result).toEqual({ error: 'Buylist is not enabled for this game', status: 400 });
});
it('returns instantQuoteDisabled when any involved game has web instant quotes off', async () => {
const deps = makeDeps();
deps.getGameBuylistConfig.mockReturnValue({ enabled: true, instantQuote: { web: false } });
_setDeps(deps);
const result = await quotePublicLines(store, lines);
expect(result).toEqual({ instantQuoteDisabled: true });
expect(deps.priceSelectedLines).not.toHaveBeenCalled();
});
it('prices and totals the lines when enabled and instant quotes are on', async () => {
const deps = makeDeps();
const buylistConfig = { enabled: true, instantQuote: { web: true } };
deps.getGameBuylistConfig.mockReturnValue(buylistConfig);
const pricedLines = [{ ...lines[0], matchStatus: 'matched', offerPrice: 5 }];
deps.priceSelectedLines.mockResolvedValue({ lines: pricedLines });
const totals = { cashTotal: 5, creditTotal: 6.25, creditBonus: 0.25, marketTotal: 10 };
deps.computeOrderTotals.mockReturnValue(totals);
_setDeps(deps);
const result = await quotePublicLines(store, lines);
expect(deps.priceSelectedLines).toHaveBeenCalledWith({ store, lines });
expect(deps.computeOrderTotals).toHaveBeenCalledWith(pricedLines, { mtg: buylistConfig });
expect(result).toEqual({ lines: pricedLines, totals });
});
});describe('createPublicOrder', ...) โ drop 'mtg' as a call argument everywhere, remove game from the expect(deps.BuylistOrder).toHaveBeenCalledWith(...) matcher and from savedDoc, and remove game: 'mtg' from makeOrderDoc's defaults (used by the accept/decline tests further down the file, which don't otherwise reference game, so removing it there is safe):
js
describe('createPublicOrder', () => {
const store = { shop: 'test.myshopify.com' };
const lines = [{ cardUuid: 'card-1', game: 'mtg', finish: 'normal', condition: 'nm', quantity: 1 }];
const customer = { email: 'buyer@example.com', name: 'Sam' };
it('returns a 400-shaped error for an unsupported game', async () => {
const deps = makeDeps();
_setDeps(deps);
const result = await createPublicOrder(store, [{ ...lines[0], game: 'yugioh' }], customer);
expect(result).toEqual({ error: 'Unsupported game(s): yugioh', status: 400 });
});
it('returns the disabled-error shape when buylist is disabled for the game', async () => {
const deps = makeDeps();
deps.getGameBuylistConfig.mockReturnValue({ enabled: false });
deps.assertBuylistEnabled.mockReturnValue('Buylist is not enabled for this game');
_setDeps(deps);
const result = await createPublicOrder(store, lines, customer);
expect(result).toEqual({ error: 'Buylist is not enabled for this game', status: 400 });
});
it('prices the lines, saves a storefront-sourced pending order, and returns a one-time claim token', async () => {
const deps = makeDeps();
const buylistConfig = { enabled: true, storeCreditBonus: 0.25 };
deps.getGameBuylistConfig.mockReturnValue(buylistConfig);
const pricedLines = [{ ...lines[0], matchStatus: 'matched', offerPrice: 5, basisPrice: 10, included: true }];
deps.priceSelectedLines.mockResolvedValue({ lines: pricedLines });
const totals = { cashTotal: 5, creditTotal: 6.25, creditBonus: 0.25, marketTotal: 10 };
deps.computeOrderTotals.mockReturnValue(totals);
deps.generateClaimToken.mockReturnValue('raw-token-value');
deps.hashClaimToken.mockReturnValue('hashed-token-value');
const savedDoc = {
_id: { toString: () => 'order-id-1' },
status: 'pending',
customer,
lines: pricedLines,
payout: { method: 'store_credit', cashTotal: 5, creditTotal: 6.25, creditBonus: 0.25, marketTotal: 10 },
createdAt: new Date('2026-07-18T00:00:00Z'),
save: vi.fn().mockResolvedValue(undefined)
};
deps.BuylistOrder = vi.fn().mockImplementation(function (doc) {
return Object.assign(savedDoc, doc, { save: savedDoc.save });
});
_setDeps(deps);
const result = await createPublicOrder(store, lines, customer);
expect(deps.BuylistOrder).toHaveBeenCalledWith(expect.objectContaining({
shop: 'test.myshopify.com',
source: 'storefront',
status: 'pending',
customer,
claimTokenHash: 'hashed-token-value',
lines: pricedLines
}));
expect(savedDoc.save).toHaveBeenCalled();
expect(result.claimToken).toBe('raw-token-value');
expect(result.order.id).toBe('order-id-1');
expect(result.order.claimTokenHash).toBeUndefined();
});
it('redacts offerPrice and payout totals from the returned order while it is pending', async () => {
const deps = makeDeps();
const buylistConfig = { enabled: true, storeCreditBonus: 0.25 };
deps.getGameBuylistConfig.mockReturnValue(buylistConfig);
const pricedLines = [{ ...lines[0], matchStatus: 'matched', offerPrice: 5, basisPrice: 10, included: true }];
deps.priceSelectedLines.mockResolvedValue({ lines: pricedLines });
const totals = { cashTotal: 5, creditTotal: 6.25, creditBonus: 0.25, marketTotal: 10 };
deps.computeOrderTotals.mockReturnValue(totals);
const savedDoc = {
_id: { toString: () => 'order-id-2' },
status: 'pending',
customer,
lines: pricedLines,
payout: { method: 'store_credit', cashTotal: 5, creditTotal: 6.25, creditBonus: 0.25, marketTotal: 10 },
createdAt: new Date('2026-07-18T00:00:00Z'),
save: vi.fn().mockResolvedValue(undefined)
};
deps.BuylistOrder = vi.fn().mockImplementation(function (doc) {
return Object.assign(savedDoc, doc, { save: savedDoc.save });
});
_setDeps(deps);
const result = await createPublicOrder(store, lines, customer);
expect(result.order.status).toBe('pending');
expect(result.order.lines[0].offerPrice).toBeUndefined();
expect(result.order.lines[0].quantity).toBe(1);
expect(result.order.payout.cashTotal).toBeUndefined();
expect(result.order.payout.creditTotal).toBeUndefined();
expect(result.order.payout.marketTotal).toBeUndefined();
expect(result.order.payout.method).toBe('store_credit');
});
});And in makeOrderDoc (used by the getPublicOrderByClaim/actOnPublicOrder tests further down), drop the game: 'mtg' default line โ nothing in those describe blocks asserts on it.
- [ ] Step 2: Run tests to verify they fail
Run: npx vitest run server/services/publicBuylistService.test.js Expected: FAIL โ implementation still takes a game parameter and calls the old single-game priceSelectedLines/computeOrderTotals shapes.
- [ ] Step 3: Implement the service changes
Edit server/services/publicBuylistService.js:
js
const { getGameBuylistConfig, assertBuylistEnabled, resolveLineGameConfigs } = require('./buylistConfigService');js
function getDeps() {
if (_deps) return _deps;
return {
BuylistOrder, Store, getPlugin, getSupportedGames,
getGameBuylistConfig, assertBuylistEnabled, resolveLineGameConfigs,
priceSelectedLines, computeOrderTotals,
generateClaimToken, hashClaimToken, logger
};
}js
async function quotePublicLines(store, lines, deps = getDeps()) {
const resolved = deps.resolveLineGameConfigs(store, lines);
if (resolved.error) return { error: resolved.error, status: resolved.status };
const anyInstantQuoteDisabled = Object.values(resolved.configsByGame).some((c) => !c.instantQuote.web);
if (anyInstantQuoteDisabled) {
return { instantQuoteDisabled: true };
}
const quote = await deps.priceSelectedLines({ store, lines });
const totals = deps.computeOrderTotals(quote.lines, resolved.configsByGame);
return { lines: quote.lines, totals };
}js
async function createPublicOrder(store, lines, customer, deps = getDeps()) {
const resolved = deps.resolveLineGameConfigs(store, lines);
if (resolved.error) return { error: resolved.error, status: resolved.status };
const quote = await deps.priceSelectedLines({ store, lines });
const totals = deps.computeOrderTotals(quote.lines, resolved.configsByGame);
const claimToken = deps.generateClaimToken();
const order = new deps.BuylistOrder({
shop: store.shop,
source: 'storefront',
status: 'pending',
customer,
claimTokenHash: deps.hashClaimToken(claimToken),
lines: quote.lines,
payout: {
method: 'store_credit',
cashTotal: totals.cashTotal,
creditTotal: totals.creditTotal,
creditBonus: totals.creditBonus,
marketTotal: totals.marketTotal
}
});
await order.save();
deps.logger.info('Created public buylist order', { shop: store.shop, games: Object.keys(resolved.configsByGame), orderId: order._id, source: 'storefront' });
return { order: toPublicOrder(order), claimToken };
}toPublicOrder โ drop the game: order.game line (the client now reads each line's own game, same as the merchant review page):
js
function toPublicOrder(order) {
const isPending = order.status === 'pending';
return {
id: order._id.toString(),
status: order.status,
customer: { email: order.customer.email, name: order.customer.name },
lines: isPending ? order.lines.map(redactLinePricing) : order.lines,
payout: isPending ? redactPayoutPricing(order.payout) : order.payout,
createdAt: order.createdAt
};
}getPublicStoreSummary is unaffected โ it already returns the store's enabled games list, not anything about individual orders.
Then edit server/routes/publicBuylist.js to stop passing req.body.game (now removed from the schema by Task 6):
js
async function handleQuote(req, res) {
try {
const { quotePublicLines } = require('../services/publicBuylistService');
const result = await quotePublicLines(req.publicStore, req.body.lines);
if (result.error) return res.status(result.status).json({ error: result.error });
res.json(result);
} catch (error) {
logger.error('Failed to generate public buylist quote', { error: error.message, shop: req.params.shop });
res.status(500).json({ error: 'Failed to generate quote' });
}
}
async function handleCreateOrder(req, res) {
try {
const { createPublicOrder } = require('../services/publicBuylistService');
const result = await createPublicOrder(req.publicStore, req.body.lines, req.body.customer);
if (result.error) return res.status(result.status).json({ error: result.error });
res.status(201).json(result);
} catch (error) {
logger.error('Failed to create public buylist order', { error: error.message, shop: req.params.shop });
res.status(500).json({ error: 'Failed to create order' });
}
}- [ ] Step 4: Update
server/routes/publicBuylist.test.jsand run all affected tests
This file's handleQuote/handleCreateOrder tests build req.body literals containing game: 'mtg' and, for handleQuote, assert quotePublicLines was called with it as a positional argument. Update both:
js
describe('handleQuote', () => {
it('returns the quote result', async () => {
vi.spyOn(serviceModule, 'quotePublicLines').mockResolvedValue({ lines: [], totals: {} });
const req = { params: { shop: 'test.myshopify.com' }, body: { lines: [] }, publicStore: { shop: 'test.myshopify.com' } };
await routesModule.handleQuote(req, res);
expect(serviceModule.quotePublicLines).toHaveBeenCalledWith(req.publicStore, []);
expect(res.json).toHaveBeenCalledWith({ lines: [], totals: {} });
});
it('maps a service error to its status code', async () => {
vi.spyOn(serviceModule, 'quotePublicLines').mockResolvedValue({ error: 'Buylist is not enabled for this game', status: 400 });
const req = { params: { shop: 'test.myshopify.com' }, body: { lines: [] }, publicStore: {} };
await routesModule.handleQuote(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: 'Buylist is not enabled for this game' });
});
});
describe('handleCreateOrder', () => {
it('returns 201 with the created order and claim token', async () => {
vi.spyOn(serviceModule, 'createPublicOrder').mockResolvedValue({ order: { id: 'order-1' }, claimToken: 'raw-token' });
const req = {
params: { shop: 'test.myshopify.com' },
body: { lines: [], customer: { email: 'buyer@example.com' } },
publicStore: { shop: 'test.myshopify.com' }
};
await routesModule.handleCreateOrder(req, res);
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith({ order: { id: 'order-1' }, claimToken: 'raw-token' });
});
});Run: npx vitest run server/services/publicBuylistService.test.js server/routes/publicBuylist.test.js Expected: PASS
- [ ] Step 5: Commit
bash
git add server/services/publicBuylistService.js server/services/publicBuylistService.test.js server/routes/publicBuylist.js server/routes/publicBuylist.test.js
git commit -m "Support multi-game lines in the public buylist portal's quote and create paths"Task 8: POST /api/buylist/orders โ multi-game create โ
Files:
- Modify:
server/routes/api.js(lines ~1974-2028, thePOST /buylist/ordershandler)
Interfaces:
Consumes:
resolveLineGameConfigs(Task 3),priceSelectedLines/computeOrderTotalsnew signatures (Task 4),createBuylistOrderSchemanew shape (Task 5).[ ] Step 1: Replace the handler
js
router.post('/buylist/orders', async (req, res) => {
try {
const { createBuylistOrderSchema } = require('../schemas');
const parsed = createBuylistOrderSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'Validation failed',
details: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message }))
});
}
const { generateQuote, priceSelectedLines, computeOrderTotals } = require('../services/buylistQuoteService');
const { getGameBuylistConfig, assertBuylistEnabled, resolveLineGameConfigs } = require('../services/buylistConfigService');
const { isGameSupported } = require('../plugins');
let quote;
let buylistConfigsByGame;
if (parsed.data.rawText) {
const game = parsed.data.game;
if (!isGameSupported(game)) {
return res.status(400).json({ error: `Missing or unsupported game: ${game}` });
}
const buylistConfig = getGameBuylistConfig(req.store, game);
const disabledError = assertBuylistEnabled(buylistConfig);
if (disabledError) return res.status(400).json({ error: disabledError });
quote = await generateQuote({ store: req.store, game, rawText: parsed.data.rawText, format: parsed.data.format });
buylistConfigsByGame = { [game]: buylistConfig };
} else {
const resolved = resolveLineGameConfigs(req.store, parsed.data.lines);
if (resolved.error) return res.status(resolved.status).json({ error: resolved.error });
buylistConfigsByGame = resolved.configsByGame;
quote = await priceSelectedLines({ store: req.store, lines: parsed.data.lines });
}
const totals = computeOrderTotals(quote.lines, buylistConfigsByGame);
const BuylistOrder = require('../models/BuylistOrder');
const order = new BuylistOrder({
shop: req.store.shop,
source: 'merchant',
status: 'pending',
customer: parsed.data.customer,
lines: quote.lines,
payout: {
method: parsed.data.payoutMethod || 'store_credit',
cashTotal: totals.cashTotal,
creditTotal: totals.creditTotal,
creditBonus: totals.creditBonus,
marketTotal: totals.marketTotal
}
});
await order.save();
logger.info('Created buylist order', { shop: req.store.shop, games: Object.keys(buylistConfigsByGame), orderId: order._id, lineCount: order.lines.length });
res.status(201).json({ order });
} catch (error) {
logger.error('Failed to create buylist order', { error: error.message });
res.status(500).json({ error: 'Failed to create buylist order' });
}
});- [ ] Step 2: Manual smoke test against a running dev server
Run: npm run dev (or npm run dev:no-worker), then with a valid session cookie/JWT for a dev store that has both MTG and Pokemon buylist enabled:
bash
curl -X POST http://localhost:3000/api/buylist/orders \
-H "Content-Type: application/json" -H "Cookie: <dev session>" \
-d '{"customer":{"email":"a@b.com"},"lines":[{"cardUuid":"<real mtg uuid>","game":"mtg","finish":"","condition":"nm","quantity":1},{"cardUuid":"<real pokemon uuid>","game":"pokemon","finish":"","condition":"nm","quantity":1}]}'Expected: 201 with an order whose lines contains both cards, each with its own game.
- [ ] Step 3: Run the full server test suite
Run: npm test Expected: PASS (all 1242+ existing tests, plus everything added in Tasks 1-7)
- [ ] Step 4: Commit
bash
git add server/routes/api.js
git commit -m "Support cross-game lines in POST /api/buylist/orders"Task 9: GET /api/buylist/orders โ optional game filter โ
Files:
Modify:
server/routes/api.js(lines ~2034-2063)[ ] Step 1: Replace the handler
js
router.get('/buylist/orders', async (req, res) => {
try {
const game = req.query.game;
const { isGameSupported } = require('../plugins');
if (game && !isGameSupported(game)) {
return res.status(400).json({ error: `Unsupported game: ${game}` });
}
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 20));
const filter = { shop: req.store.shop };
if (game) filter['lines.game'] = game;
if (req.query.status) filter.status = req.query.status;
const BuylistOrder = require('../models/BuylistOrder');
const [orders, total] = await Promise.all([
BuylistOrder.find(filter)
.sort({ createdAt: -1 })
.skip((page - 1) * limit)
.limit(limit)
.lean(),
BuylistOrder.countDocuments(filter)
]);
res.json({ orders, pagination: { page, limit, total, pages: Math.ceil(total / limit) } });
} catch (error) {
logger.error('Failed to list buylist orders', { error: error.message });
res.status(500).json({ error: 'Failed to list buylist orders' });
}
});(Only the game requirement and the filter construction changed โ pagination/sort/response shape are untouched.)
- [ ] Step 2: Manual smoke test
With the dev server running: curl http://localhost:3000/api/buylist/orders -H "Cookie: <dev session>" (no game param) should return orders across every game. curl "http://localhost:3000/api/buylist/orders?game=mtg" -H "Cookie: <dev session>" should return only orders with at least one MTG line, including any mixed-game order.
- [ ] Step 3: Run the full server test suite
Run: npm test Expected: PASS
- [ ] Step 4: Commit
bash
git add server/routes/api.js
git commit -m "Make the game filter optional on GET /api/buylist/orders"Task 10: PATCH /api/buylist/orders/:id โ per-line reprice โ
Files:
Modify:
server/routes/api.js(lines ~2095-2141)[ ] Step 1: Replace the totals-recompute section
Only the two lines building buylistConfig/calling computeOrderTotals change; everything above them (schema validation, order lookup, status check, line-length check, applying included/offerPrice edits) is unchanged:
js
const { computeOrderTotals } = require('../services/buylistQuoteService');
const { getGameBuylistConfig } = require('../services/buylistConfigService');
const distinctGames = [...new Set(order.lines.map((l) => l.game))];
const buylistConfigsByGame = Object.fromEntries(distinctGames.map((g) => [g, getGameBuylistConfig(req.store, g)]));
const totals = computeOrderTotals(order.lines, buylistConfigsByGame);- [ ] Step 2: Manual smoke test
Create a mixed-game order via Task 8's curl example, note its _id, then:
bash
curl -X PATCH http://localhost:3000/api/buylist/orders/<id> \
-H "Content-Type: application/json" -H "Cookie: <dev session>" \
-d '{"lines":[{"included":true,"offerPrice":5},{"included":true,"offerPrice":3}]}'Expected: 200 with recomputed payout totals that reflect each line's own game's storeCreditBonus.
- [ ] Step 3: Run the full server test suite
Run: npm test Expected: PASS
- [ ] Step 4: Commit
bash
git add server/routes/api.js
git commit -m "Reprice mixed-game buylist orders using each line's own game config"Task 11: NewBuyInTab.jsx โ stop clearing the cart, stamp game per card, show badges โ
Files:
- Modify:
client/src/pages/buylist/NewBuyInTab.jsx - Modify:
client/src/pages/buylist/NewBuyInTab.test.jsx
Interfaces:
Produces: cart rows now carry a
gamefield, stamped from whichever tab was active when the card was added; switching tabs no longer clearsselectedCards.[ ] Step 1: Update the failing/changed tests
In client/src/pages/buylist/NewBuyInTab.test.jsx:
a) The two paste-mode api.post assertions ('submits the pasted list...' and 'lets the merchant choose the ManaBox CSV format') change from '/buylist/orders?game=mtg' to '/buylist/orders' with game: 'mtg' added into the body:
js
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
'/buylist/orders',
{ customer: { email: 'jamie@example.com' }, game: 'mtg', rawText: '1 Lightning Bolt [CLB] 187', format: 'plain' }
));js
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
'/buylist/orders',
{ customer: { email: 'jamie@example.com' }, game: 'mtg', rawText: 'Name,Quantity\nLightning Bolt,1\n', format: 'manabox' }
));b) In the 'NewBuyInTab โ search & select mode' block, the 'adds a searched card...' test's api.post assertion drops the query string and moves game onto the line:
js
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
'/buylist/orders',
{
customer: { email: 'jamie@example.com' },
lines: [{ cardUuid: 'uuid-1', game: 'mtg', finish: 'Nonfoil', condition: 'nm', quantity: 1 }]
}
));c) Add a new test for the actual bug fix โ switching games preserves the cart and tags the new card with the new game. This needs a second game in the mocked /games response and a second /catalog/cards result:
js
it('keeps existing cart rows when switching games, tagging each with the game active when it was added', async () => {
api.get.mockImplementation((url) => {
if (url === '/games') return Promise.resolve({ data: { games: [
{ id: 'mtg', name: 'Magic: The Gathering' },
{ id: 'pokemon', name: 'Pokemon' }
] } });
if (url === '/catalog/cards') return Promise.resolve({ data: { cards: [
{ uuid: 'uuid-1', name: 'Ragavan, Nimble Pilferer', number: '138', setCode: 'MH2', setName: 'Modern Horizons 2', rarity: 'mythic', finishes: ['Nonfoil', 'Foil'] },
{ uuid: 'uuid-2', name: 'Pikachu', number: '25', setCode: 'BASE', setName: 'Base Set', rarity: 'common', finishes: ['Nonfoil'] }
] } });
return Promise.resolve({ data: {} });
});
render(<MemoryRouter><NewBuyInTab /></MemoryRouter>);
fireEvent.change(await screen.findByLabelText(/customer email/i), { target: { value: 'jamie@example.com' } });
fireEvent.click(screen.getByRole('button', { name: /search & select/i }));
fireEvent.change(screen.getByLabelText(/search cards/i), { target: { value: 'Ragavan' } });
fireEvent.click(await screen.findByText('Ragavan, Nimble Pilferer'));
await screen.findByText('Ragavan, Nimble Pilferer', { selector: 'td *, td' });
fireEvent.click(screen.getByRole('button', { name: /^pokemon$/i }));
fireEvent.change(screen.getByLabelText(/search cards/i), { target: { value: 'Pikachu' } });
fireEvent.click(await screen.findByText('Pikachu'));
expect(screen.getByText('Ragavan, Nimble Pilferer', { selector: 'td *, td' })).toBeInTheDocument();
expect(screen.getByText('Pikachu', { selector: 'td *, td' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /generate offer/i }));
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
'/buylist/orders',
{
customer: { email: 'jamie@example.com' },
lines: [
{ cardUuid: 'uuid-1', game: 'mtg', finish: 'Nonfoil', condition: 'nm', quantity: 1 },
{ cardUuid: 'uuid-2', game: 'pokemon', finish: 'Nonfoil', condition: 'nm', quantity: 1 }
]
}
));
});- [ ] Step 2: Run tests to verify they fail
Run: npx vitest run client/src/pages/buylist/NewBuyInTab.test.jsx --config vitest.client.config.js Expected: FAIL โ the component still clears selectedCards on game switch, still posts to ?game= in the URL, and cart rows don't carry game.
- [ ] Step 3: Implement the component changes
Edit client/src/pages/buylist/NewBuyInTab.jsx:
jsx
const addCard = (card) => {
setSelectedCards((prev) => {
if (prev.some((c) => c.uuid === card.uuid)) return prev; // no duplicate rows
return [...prev, {
uuid: card.uuid,
game,
name: card.name,
setCode: card.setCode,
number: card.number,
finishes: card.finishes || [],
finish: getDefaultFinish(card),
condition: 'nm',
quantity: 1
}];
});
};jsx
const submit = async () => {
setSubmitting(true);
setError('');
try {
const customer = name ? { email, name } : { email };
const body = mode === 'paste'
? { customer, game, rawText, format }
: {
customer,
lines: selectedCards.map((c) => ({
cardUuid: c.uuid,
game: c.game,
finish: c.finish,
condition: c.condition,
quantity: Number(c.quantity)
}))
};
const { data } = await api.post('/buylist/orders', body);
navigate(`/buylist/orders/${data.order._id}`);
} catch (err) {
setError(err.response?.data?.error || 'Failed to generate the offer');
} finally {
setSubmitting(false);
}
};The game-tab buttons' onClick={() => setGame(g.id)} is unchanged โ it already only sets game, and now that addCard reads game at add-time instead of the cart being cleared, no clearing behavior needs to be removed (there was never a selectedCards-clearing effect keyed on game, since Task discovery found the bug is an absence of a reset, not a reset to remove โ the fix is entirely in what addCard/submit do with game, not in setGame itself).
Add a game badge column to the cart table โ insert a new <th> after "Card" and a matching <td> per row, using the already-fetched games list to resolve a display name:
jsx
<th className="px-4 py-2 text-left">Game</th>jsx
<td className="px-4 py-2 text-muted-foreground">
{games.find((g) => g.id === c.game)?.name || c.game}
</td>(Placed as the second <td> in each row, matching the new <th>'s position โ after "Card", before "Set".)
- [ ] Step 4: Run tests to verify they pass
Run: npx vitest run client/src/pages/buylist/NewBuyInTab.test.jsx --config vitest.client.config.js Expected: PASS
- [ ] Step 5: Run the full client test suite
Run: npm run test:client Expected: PASS
- [ ] Step 6: Commit
bash
git add client/src/pages/buylist/NewBuyInTab.jsx client/src/pages/buylist/NewBuyInTab.test.jsx
git commit -m "Let the merchant buy-in cart span games instead of resetting on tab switch"Task 12: BuylistPortalPage.jsx โ same cart fix for the customer portal โ
Files:
- Modify:
client/src/pages/portal/BuylistPortalPage.jsx - Modify:
client/src/pages/portal/BuylistPortalPage.test.jsx
Interfaces:
Produces: same as Task 11 โ cart rows carry
game, submit sendsgameper line, no top-levelgamein the request body (the portal has no paste mode).[ ] Step 1: Update the failing/changed tests
In client/src/pages/portal/BuylistPortalPage.test.jsx, the 'submits the order...' test's publicApi.post assertion drops the top-level game and adds it to the line:
js
await waitFor(() => expect(publicApi.post).toHaveBeenCalledWith(
'/public/buylist/test-store.myshopify.com/orders',
{ customer: { email: 'buyer@example.com' }, lines: [{ cardUuid: 'uuid-1', game: 'mtg', finish: 'Nonfoil', condition: 'nm', quantity: 1 }] }
));Add a new test mirroring Task 11's cart-preservation test, using a second game in storeSummary and a second /catalog/cards mock result:
js
it('keeps existing cart rows when switching games', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
publicApi.get.mockImplementation((url) => {
if (url === '/public/buylist/test-store.myshopify.com') return Promise.resolve({ data: {
shop: 'test-store.myshopify.com',
games: [{ id: 'mtg', name: 'Magic: The Gathering' }, { id: 'pokemon', name: 'Pokemon' }]
} });
if (url === '/catalog/cards') return Promise.resolve({ data: { cards: [
{ uuid: 'uuid-1', name: 'Ragavan, Nimble Pilferer', number: '138', setCode: 'MH2', setName: 'Modern Horizons 2', rarity: 'mythic', finishes: ['Nonfoil'] },
{ uuid: 'uuid-2', name: 'Pikachu', number: '25', setCode: 'BASE', setName: 'Base Set', rarity: 'common', finishes: ['Nonfoil'] }
] } });
return Promise.resolve({ data: {} });
});
renderAt('test-store.myshopify.com');
await screen.findByText('Magic: The Gathering');
fireEvent.change(screen.getByLabelText(/search cards/i), { target: { value: 'Ragavan' } });
vi.advanceTimersByTime(300);
fireEvent.click(await screen.findByText('Ragavan, Nimble Pilferer'));
fireEvent.click(screen.getByRole('button', { name: /^pokemon$/i }));
fireEvent.change(screen.getByLabelText(/search cards/i), { target: { value: 'Pikachu' } });
vi.advanceTimersByTime(300);
fireEvent.click(await screen.findByText('Pikachu'));
expect(screen.getByText('Ragavan, Nimble Pilferer')).toBeInTheDocument();
expect(screen.getByText('Pikachu')).toBeInTheDocument();
vi.useRealTimers();
});- [ ] Step 2: Run tests to verify they fail
Run: npx vitest run client/src/pages/portal/BuylistPortalPage.test.jsx --config vitest.client.config.js Expected: FAIL
- [ ] Step 3: Implement the component changes
Edit client/src/pages/portal/BuylistPortalPage.jsx:
jsx
const addCard = (card) => {
setSelectedCards((prev) => {
if (prev.some((c) => c.uuid === card.uuid)) return prev;
return [...prev, {
uuid: card.uuid,
game,
name: card.name,
setCode: card.setCode,
number: card.number,
finishes: card.finishes || [],
finish: card.finishes?.length === 1 ? card.finishes[0] : getDefaultFinish(card),
condition: 'nm',
quantity: 1
}];
});
};jsx
const submit = async () => {
setSubmitting(true);
setError('');
try {
const customer = name ? { email, name } : { email };
const body = {
customer,
lines: selectedCards.map((c) => ({
cardUuid: c.uuid,
game: c.game,
finish: c.finish,
condition: c.condition,
quantity: Number(c.quantity)
}))
};
const { data } = await publicApi.post(`/public/buylist/${shop}/orders`, body);
setResult({ orderId: data.order.id, claimToken: data.claimToken });
} catch (err) {
setError(err.response?.data?.error || 'Failed to submit your list');
} finally {
setSubmitting(false);
}
};Add a game badge column to the cart table, same placement as Task 11 (after "Card", before "Set"):
jsx
<th className="px-4 py-2 text-left">Game</th>jsx
<td className="px-4 py-2 text-muted-foreground">
{summary.games.find((g) => g.id === c.game)?.name || c.game}
</td>- [ ] Step 4: Run tests to verify they pass
Run: npx vitest run client/src/pages/portal/BuylistPortalPage.test.jsx --config vitest.client.config.js Expected: PASS
- [ ] Step 5: Run the full client test suite
Run: npm run test:client Expected: PASS
- [ ] Step 6: Commit
bash
git add client/src/pages/portal/BuylistPortalPage.jsx client/src/pages/portal/BuylistPortalPage.test.jsx
git commit -m "Let the customer portal buylist cart span games instead of resetting on tab switch"Task 13: BuylistOrdersTab.jsx โ "All" tab default, per-row game badges โ
Files:
Modify:
client/src/pages/buylist/BuylistOrdersTab.jsxModify:
client/src/pages/buylist/BuylistOrdersTab.test.jsx[ ] Step 1: Update the failing/changed tests
js
const ordersResponse = {
data: {
orders: [
{
_id: 'order-1',
customer: { email: 'jamie@example.com' },
status: 'pending',
lines: [{ included: true, game: 'mtg' }, { included: true, game: 'pokemon' }],
payout: { method: 'store_credit', cashTotal: 24.25, creditTotal: 30.31, marketTotal: 48.50 },
createdAt: '2026-07-16T12:00:00Z'
}
],
pagination: { page: 1, limit: 20, total: 1, pages: 1 }
}
};js
describe('BuylistOrdersTab', () => {
it('defaults to All and lists orders across every game', async () => {
renderWithRouter(<BuylistOrdersTab />);
await waitFor(() => expect(api.get).toHaveBeenCalledWith('/buylist/orders'));
expect(await screen.findByText('jamie@example.com')).toBeInTheDocument();
expect(screen.getByText(/pending/i)).toBeInTheDocument();
});
it('filters by game when a specific game tab is selected', async () => {
renderWithRouter(<BuylistOrdersTab />);
await screen.findByText('jamie@example.com');
fireEvent.click(screen.getByRole('button', { name: /^magic: the gathering$/i }));
await waitFor(() => expect(api.get).toHaveBeenCalledWith('/buylist/orders?game=mtg'));
});
it('shows a link to each order', async () => {
renderWithRouter(<BuylistOrdersTab />);
const link = await screen.findByRole('link', { name: /jamie@example.com/i });
expect(link).toHaveAttribute('href', '/buylist/orders/order-1');
});
it('shows market, cash, and store credit totals as separate columns', async () => {
renderWithRouter(<BuylistOrdersTab />);
const row = (await screen.findByText('jamie@example.com')).closest('tr');
expect(row).toHaveTextContent('48.50');
expect(row).toHaveTextContent('24.25');
expect(row).toHaveTextContent('30.31');
});
it('shows a badge per distinct game present in the order\'s lines', async () => {
renderWithRouter(<BuylistOrdersTab />);
const row = (await screen.findByText('jamie@example.com')).closest('tr');
expect(row).toHaveTextContent('Magic: The Gathering');
expect(row).toHaveTextContent('pokemon');
});
});(The last test expects the raw id 'pokemon' for the second badge because gamesResponse in this file's fixtures only defines mtg โ matching the fallback-to-raw-id behavior for a game not in the merchant's currently-fetched list, the same defensive fallback used in Tasks 11-12.)
Add fireEvent to this file's existing @testing-library/react import.
- [ ] Step 2: Run tests to verify they fail
Run: npx vitest run client/src/pages/buylist/BuylistOrdersTab.test.jsx --config vitest.client.config.js Expected: FAIL
- [ ] Step 3: Implement the component changes
Edit client/src/pages/buylist/BuylistOrdersTab.jsx:
jsx
export default function BuylistOrdersTab() {
const [games, setGames] = useState([]);
const [game, setGame] = useState('all');
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get('/games').then(({ data }) => setGames(data.games || [])).catch(() => setGames([]));
}, []);
useEffect(() => {
let ignore = false;
setLoading(true);
const query = game === 'all' ? '' : `?game=${game}`;
api.get(`/buylist/orders${query}`)
.then(({ data }) => { if (!ignore) setOrders(data.orders || []); })
.catch(() => { if (!ignore) setOrders([]); })
.finally(() => { if (!ignore) setLoading(false); });
return () => { ignore = true; };
}, [game]);
const gameName = (id) => games.find((g) => g.id === id)?.name || id;
return (
<div className="space-y-4">
<div className="flex gap-2">
<Button variant={game === 'all' ? 'default' : 'outline'} size="sm" onClick={() => setGame('all')}>
All
</Button>
{games.map((g) => (
<Button
key={g.id}
variant={g.id === game ? 'default' : 'outline'}
size="sm"
onClick={() => setGame(g.id)}
>
{g.name}
</Button>
))}
</div>
{loading ? (
<Text>Loading ordersโฆ</Text>
) : orders.length === 0 ? (
<Text>No buy orders yet.</Text>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-secondary text-secondary-foreground border-b-2 border-black">
<tr>
<th className="px-4 py-2 text-left">Customer</th>
<th className="px-4 py-2 text-left">Games</th>
<th className="px-4 py-2 text-left">Lines</th>
<th className="px-4 py-2 text-left">Market</th>
<th className="px-4 py-2 text-left">Cash Offer</th>
<th className="px-4 py-2 text-left">Store Credit Offer</th>
<th className="px-4 py-2 text-left">Status</th>
<th className="px-4 py-2 text-left">Received</th>
</tr>
</thead>
<tbody>
{orders.map((order) => (
<tr key={order._id} className="border-b border-black/20 hover:bg-muted">
<td className="px-4 py-2">
<Link to={`/buylist/orders/${order._id}`} className="font-semibold underline">
{order.customer.email}
</Link>
</td>
<td className="px-4 py-2 space-x-1">
{[...new Set(order.lines.map((l) => l.game))].map((g) => (
<Badge key={g}>{gameName(g)}</Badge>
))}
</td>
<td className="px-4 py-2">{order.lines.length}</td>
<td className="px-4 py-2">{typeof order.payout.marketTotal === 'number' ? order.payout.marketTotal.toFixed(2) : 'โ'}</td>
<td className="px-4 py-2">{order.payout.cashTotal.toFixed(2)}</td>
<td className="px-4 py-2">{order.payout.creditTotal.toFixed(2)}</td>
<td className="px-4 py-2"><Badge>{STATUS_LABEL[order.status] || order.status}</Badge></td>
<td className="px-4 py-2">{new Date(order.createdAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}- [ ] Step 4: Run tests to verify they pass
Run: npx vitest run client/src/pages/buylist/BuylistOrdersTab.test.jsx --config vitest.client.config.js Expected: PASS
- [ ] Step 5: Run the full client test suite
Run: npm run test:client Expected: PASS
- [ ] Step 6: Commit
bash
git add client/src/pages/buylist/BuylistOrdersTab.jsx client/src/pages/buylist/BuylistOrdersTab.test.jsx
git commit -m "Default the buy orders list to All games and show per-order game badges"Task 14: BuylistReviewPage.jsx โ per-line game badge โ
Files:
- Modify:
client/src/pages/buylist/BuylistReviewPage.jsx - Modify:
client/src/pages/buylist/BuylistReviewPage.test.jsx
Interfaces:
Consumes:
GET /games(not currently called by this page โ added here, same pattern asBuylistOrdersTab.jsx/NewBuyInTab.jsx).[ ] Step 1: Update the fixture and add a failing test
client/src/pages/buylist/BuylistReviewPage.test.jsx mocks api.get with one blanket mockResolvedValue({ data: { order: baseOrder } }) in beforeEach, covering every api.get call site (there's only one today: GET /buylist/orders/:id). Once the component adds a second call to GET /games, that same blanket mock still resolves for it too โ data.games is simply undefined on the { order: baseOrder } payload, and setGames(data.games || []) already defaults that to [], so every existing test keeps passing unmodified with the game badge falling back to the raw game id.
First add game: 'mtg' to both lines in the shared baseOrder fixture, since every line is now required to carry one:
js
const baseOrder = {
_id: 'order-1',
game: 'mtg',
status: 'pending',
source: 'merchant',
customer: { email: 'jamie@example.com' },
lines: [
{ rawLine: '1 Lightning Bolt [CLB] 187', matchStatus: 'matched', game: 'mtg', cardName: 'Lightning Bolt', setCode: 'CLB', collectorNumber: '187', quantity: 1, basisPrice: 2.00, offerPrice: 0.80, included: true },
{ rawLine: 'Blacc Lotus', matchStatus: 'unmatched', game: 'mtg', quantity: 1, included: false }
],
payout: { method: 'store_credit', cashTotal: 0.80, creditTotal: 1.00, creditBonus: 0.25, marketTotal: 2.00 }
};Then add a new test to the first describe('BuylistReviewPage', ...) block, overriding the mock for this test only so /games returns a real display name:
js
it('shows each line\'s game as a badge', async () => {
api.get.mockImplementation((url) => (
url === '/games'
? Promise.resolve({ data: { games: [{ id: 'mtg', name: 'Magic: The Gathering' }] } })
: Promise.resolve({ data: { order: baseOrder } })
));
renderAt('order-1');
const row = (await screen.findByText('Lightning Bolt')).closest('tr');
expect(row).toHaveTextContent('Magic: The Gathering');
});- [ ] Step 2: Run test to verify it fails
Run: npx vitest run client/src/pages/buylist/BuylistReviewPage.test.jsx --config vitest.client.config.js Expected: FAIL โ no /games fetch and no game badge rendered yet.
- [ ] Step 3: Implement the component changes
Edit client/src/pages/buylist/BuylistReviewPage.jsx:
jsx
export default function BuylistReviewPage() {
const { id } = useParams();
const [order, setOrder] = useState(null);
const [games, setGames] = useState([]);
const [error, setError] = useState('');
const [acting, setActing] = useState(false);
useEffect(() => {
api.get(`/buylist/orders/${id}`)
.then(({ data }) => setOrder(data.order))
.catch((err) => setError(err.response?.data?.error || 'Failed to load the order'));
}, [id]);
useEffect(() => {
api.get('/games').then(({ data }) => setGames(data.games || [])).catch(() => setGames([]));
}, []);Add a "Game" column, positioned as the second column (after "Card", before "Set") to match Tasks 11-12's cart tables:
jsx
<th className="px-4 py-2 text-left">Card</th>
<th className="px-4 py-2 text-left">Game</th>
<th className="px-4 py-2 text-left">Set</th>jsx
<td className="px-4 py-2">{line.cardName || line.rawLine}</td>
<td className="px-4 py-2 text-muted-foreground">
{games.find((g) => g.id === line.game)?.name || line.game}
</td>
<td className="px-4 py-2">{line.setCode || 'โ'}</td>- [ ] Step 4: Run tests to verify they pass
Run: npx vitest run client/src/pages/buylist/BuylistReviewPage.test.jsx --config vitest.client.config.js Expected: PASS
- [ ] Step 5: Run the full client test suite
Run: npm run test:client Expected: PASS
- [ ] Step 6: Commit
bash
git add client/src/pages/buylist/BuylistReviewPage.jsx client/src/pages/buylist/BuylistReviewPage.test.jsx
git commit -m "Show each buylist order line's own game on the review page"Task 15: Full-suite verification and lint โ
Files: none (verification only)
- [ ] Step 1: Run the full server suite
Run: npm test Expected: PASS, same or higher test count than the Task 0 baseline (1242 passed / 13 skipped) plus every test added in Tasks 1-10.
- [ ] Step 2: Run the full client suite
Run: npm run test:client Expected: PASS
- [ ] Step 3: Run coverage and confirm every modified file clears 70%
Run: npm run test:coverage Expected: server/models/BuylistOrder.js, server/scripts/migrations/backfillBuylistOrderLineGame.js, server/services/buylistConfigService.js, server/services/buylistQuoteService.js, server/services/publicBuylistService.js, server/schemas/buylistOrder.js, server/schemas/publicBuylist.js all report โฅ70% on lines/functions/branches/statements. If any file is below threshold, add the missing test case(s) rather than lowering the bar.
- [ ] Step 4: Lint
Run: npm run lint Expected: exits 0, no new warnings.
- [ ] Step 5: Build the client
Run: npm run build Expected: exits 0.
- [ ] Step 6: Final grep checks from the Global Constraints
Run: grep -rn "|| 'mtg'" server/ client/src/ --include=*.js --include=*.jsx Expected: no matches introduced by this plan (pre-existing matches, if any, are out of scope).
Run: grep -rn "req.query.game" server/routes/api.js Expected: only the two intentional remaining reads โ the rawText path inside POST /buylist/orders no longer reads it at all (it reads parsed.data.game now), and GET /buylist/orders's optional filter.
