Appearance
Smart Buylist Phase 2 Implementation Plan β Quote Engine + Merchant Import/Review β
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 a merchant paste a customer's card list, get it automatically matched against the global catalog and priced using the store's buylist rates, review/edit the resulting order, and accept or decline it β the full POC merchant flow (Buy Orders queue, New Buy-In, Review) wired to real data.
Architecture: A new buylistQuoteService (pure parser β plugin-driven catalog matcher β rate pipeline β composition) turns raw pasted text into BuylistOrder.lines[]. Matching is 100% plugin-driven (plugin.getProductModel() β no if (game === ...) anywhere in the engine). Basis pricing reuses priceLookupService's per-game raw-price lookups (getPricesForCard et al.) for the raw number only; the buylist rate math (rarity rate β bulk β stop-gap β condition multiplier) is new logic, since buylistConfig is a separate rate system from pricingConfig. Order creation/editing is synchronous HTTP (no BullMQ β this is bounded in-process Mongo reads, not Shopify API calls). Client-side, the existing /buylist settings page becomes a 3-tab shell (Buy Orders / New Buy-In / Settings); a new /buylist/orders/:id route is the Review screen.
Tech Stack: Node 24 / Express / Mongoose (CommonJS), Zod, Vitest (ESM tests), React 18 + retroui + Tailwind 4, React Router v6.
Spec: docs/superpowers/specs/2026-07-14-smart-buylist-customer-intake-design.md (Phase 2 of 6 β "Quote engine service + merchant-side import/review (POC screens)"). Phase 1 (merged) built BuylistOrder, Store.buylistConfig, buylistConfigService, and the settings page this plan extends.
Global Constraints β
- Server code is CommonJS (
require); ALL test files are ESM (import). gameis never defaulted anywhere (CLAUDE.md Β§5.5): missing/unsupported β 400 or throw.- Match target is
plugin.getProductModel()β returnsShopifyMTGProductVariant/ShopifyPokemonProductVariant/ShopifyRiftboundProductVariantuniformly (verified:server/plugins/{mtg,pokemon,riftbound}/index.jseach implement this identically, e.g.server/plugins/mtg/index.js:428). These are global catalogs (noshopfield) β confirmed by direct query (105,421 MTG docs, 22,485 Pokemon docs in the dev DB) and by grep (no other code references them per-store). Never query the legacy flatShopifyMTGProductmodel (server/models/ShopifyMTGProduct.js) β confirmed dead outside one legacy script (server/scripts/data-loading/syncProductsToShopify.js). - Identity/match keys:
metafields.set_code(exact, uppercase) +metafields.collector_number(exact string β notmetafields.card_number, a numeric sort key that "may collapse suffixed/promo numbers" per its own schema comment) as the primary match; case-insensitive exact-name regex onmetafields.card_nameas a fallback when set/number don't match or weren't parsed. Verified via direct DB query on bothshopify_mtg_products_variantsandshopify_pokemon_products_variants. - Rarity normalization:
(doc.metafields.rarity || 'common').toLowerCase()β the exact pattern already used inserver/services/shopifyAPI.js(two call sites). Verified real stored values are title-case ("Mythic","Common") via direct query; plugin rarity keys are lowercase (mythic,common) β lowercasing is required and suffices, no other normalization needed. - Finish handling: pass the matched variant's literal stored
finishstring (e.g."Nonfoil","normal","reverseHolofoil") straight through togetPricesForCard/getPricesForPokemonCard/getPricesForRiftboundCardas thefinishTypesarray entry. Verified by readinggetPricesForCard's body (server/services/priceLookupService.js:767-855): the returned object (and its._rawPricessub-object) is keyed by the exact string you passed in, not a normalized key β readprices._rawPrices[finish]using that same literal string. Do not try to pre-normalize finish yourself or importFINISH_MAPinto the new service β the price-lookup functions already applyFINISH_MAPinternally for bucket selection; that's a separate step from the object's outer keying. priceBasissemantics (decision confirmed with the user 2026-07-16, since the real price data doesn't support the original "Lowest Listing vs Market Price" distinction βmtgpricesstores exactly oneretail.<finish>number per source, verified by direct query, not separate market/low fields):'market'= the first available price acrossplugin.priceSourcesin order (callgetPricesForCard/etc. once with the default, unrestrictedsourcePriority).'lowest'and'lower_of'both resolve to the minimum raw price across all sources inplugin.priceSources(call once per source, restrictingsourcePriorityto that one source, thenMath.minthe valid results) β they are intentionally equivalent given the current data shape; do not build a distinct implementation for'lower_of', and do not attempt a true TCGPlayer Market-vs-Lowest-Listing split (that data isn't ingested).- Reuse, don't duplicate raw pricing: use
getPricesForCard(cardUuid, rarity, [finish], config, options)(and the Pokemon/Riftbound equivalents) to get the raw platform price. Read._rawPrices[finish], not the top-levelprices[finish]value (which may already haveconfig's sell-side modifiers/rounding/minimums baked in β irrelevant to buylist). - Buylist rate math is new logic in the new service β do not call
calculatePriceWithCondition/computePriceBreakdown(those applypricingConfig's sell-side stages;buylistConfigis a separate, already-built config shape with its ownconditionMultipliers,bulk,stopGap,rarityRates). - Plain-text parsing only. The parser handles the format the POC's textarea already demonstrates (
"<qty> <name> [<SET>] <collector#> (<condition>)", every part but the name optional). TCGPlayer-app-export and ManaBox CSV-specific importers are explicitly out of scope for this phase β documented here, not an oversight; building parsers for undocumented proprietary formats without sample files risks inventing a wrong format (the exact Β§5.4 failure class). - Manual re-matching of unmatched lines is out of scope. A merchant can exclude an unmatched line (
included: false) from the order total; attaching a catalog card to a previously-unmatched line via a search-and-pick UI is a documented follow-up, not built here. - Quote/order creation is synchronous (no BullMQ). Matching ~50-100 pasted lines against indexed Mongo collections is a bounded in-process read/compute with zero external API calls β unlike sync, which calls Shopify's rate-limited GraphQL API. Precedent:
GET /api/catalog/cards,POST /api/pricing-preview(both synchronous Mongo aggregations over the full catalog). - retroui has no Table component. Tables are plain HTML + Tailwind β
client/src/components/ProductList.jsxis the precedent (<table className="w-full text-sm">,<tr className="border-b border-black/20 hover:bg-muted">). - Server DI pattern:
_setDeps/_resetDeps/getDeps()triple, exactly as inserver/services/catalogPricingService.js(default real deps, tests inject mocks,afterEach(() => _resetDeps())). - Client editable-row pattern: controlled
useState+ curried setters bound to plain<Input>, exactly asBuylistPage.jsx's existingRateField/set()pattern. - Every literal/vocabulary choice above is grounded in a verified file or direct DB query β cite the same when extending this plan.
- Coverage β₯70% on all four metrics for every modified file;
npm run lintclean (zero new warnings); Husky pre-commit runs ESLint β never--no-verify. - Commit messages: one imperative sentence, merchant-visible outcome, sentence case, no trailing period.
- Game parity (Β§5.1): the matcher/pricer/rate-pipeline are plugin-driven by construction (mtg, pokemon, riftbound all resolve through
plugin.getProductModel()+plugin.priceSources, no per-game branching inbuylistQuoteService.js). State this in the PR description, naming all three games.
Task 0: Preflight β
Files: none (environment only)
- [ ] Step 1: Branch from the Phase 1 branch (not main β see note)
As of 2026-07-16, Phase 1's PR (#359) is open, reviewed, and mergeable, but not yet merged to main β that's the user's call, not something to do unilaterally as part of this plan. Phase 2 depends entirely on Phase 1's code (BuylistOrder, buylistConfigService, the /buylist/config routes, the settings page), so branch from the reviewed Phase 1 branch instead of main for now:
bash
git fetch origin
git checkout -b claude/buylist-phase-2 origin/claude/buylist-phase-1If PR #359 has since merged to main by the time you run this, prefer origin/main instead (it will contain the identical Phase 1 code plus whatever else has landed) β check with git log origin/main --oneline | grep -i buylist first (Β§5.10).
- [ ] Step 2: Verify the suites run green before touching anything
bash
npm run install:all
npm test 2>&1 | tail -5
npm run test:client 2>&1 | tail -5Known gotcha: in a fresh worktree npm run test:client can fail on a missing @testing-library/dom (undeclared peer dep). If so: cd client && npm install --no-save @testing-library/dom and re-run.
Task 1: Card list parser (pure function) β
Files:
- Create:
server/services/buylistQuoteService.js - Test:
server/services/buylistQuoteService.test.js
Interfaces:
Produces:
parseCardList(rawText) β Array<{ rawLine, quantity, name, setCode, collectorNumber, condition }>.setCode/collectorNumberarenullwhen absent.conditionis always one ofnm|lp|mp|hp|damaged, defaulting tonm. Task 2 consumes this directly.[ ] Step 1: Write the failing tests
server/services/buylistQuoteService.test.js:
javascript
import { describe, it, expect } from 'vitest';
import { parseCardList } from './buylistQuoteService.js';
describe('parseCardList', () => {
it('parses a line with quantity, name, set code, and collector number', () => {
const [line] = parseCardList('1 Ragavan, Nimble Pilferer [MH2] 138');
expect(line).toEqual({
rawLine: '1 Ragavan, Nimble Pilferer [MH2] 138',
quantity: 1,
name: 'Ragavan, Nimble Pilferer',
setCode: 'MH2',
collectorNumber: '138',
condition: 'nm'
});
});
it('parses a quantity with a trailing x', () => {
const [line] = parseCardList('4x Orcish Bowmasters [LTR] 103');
expect(line.quantity).toBe(4);
expect(line.name).toBe('Orcish Bowmasters');
expect(line.setCode).toBe('LTR');
expect(line.collectorNumber).toBe('103');
});
it('parses a condition in parentheses after the collector number', () => {
const [line] = parseCardList('1 Fable of the Mirror-Breaker [NEO] 141 (LP)');
expect(line.condition).toBe('lp');
expect(line.collectorNumber).toBe('141');
});
it('defaults quantity to 1 and condition to nm when omitted', () => {
const [line] = parseCardList('Lightning Bolt [CLB] 187');
expect(line.quantity).toBe(1);
expect(line.condition).toBe('nm');
});
it('handles a line with no collector number', () => {
const [line] = parseCardList('1 Sheoldred, the Apocalypse [DMU]');
expect(line.name).toBe('Sheoldred, the Apocalypse');
expect(line.setCode).toBe('DMU');
expect(line.collectorNumber).toBeNull();
});
it('falls back to a name-only line when there is no bracketed set code', () => {
const [line] = parseCardList('1 Blacc Lotus');
expect(line.name).toBe('Blacc Lotus');
expect(line.setCode).toBeNull();
expect(line.collectorNumber).toBeNull();
});
it('ignores blank lines', () => {
const lines = parseCardList('1 Lightning Bolt [CLB] 187\n\n\n1 Llanowar Elves [FDN] 226');
expect(lines).toHaveLength(2);
});
it('normalizes an unrecognized condition token to nm rather than throwing', () => {
const [line] = parseCardList('1 Lightning Bolt [CLB] 187 (mint)');
expect(line.condition).toBe('nm');
});
it('returns an empty array for blank or missing input', () => {
expect(parseCardList('')).toEqual([]);
expect(parseCardList(null)).toEqual([]);
expect(parseCardList(undefined)).toEqual([]);
});
it('uppercases a lowercase set code', () => {
const [line] = parseCardList('1 Lightning Bolt [clb] 187');
expect(line.setCode).toBe('CLB');
});
});- [ ] Step 2: Run the tests to verify they fail
bash
npx vitest run server/services/buylistQuoteService.test.jsExpected: FAIL β Cannot find module './buylistQuoteService.js'
- [ ] Step 3: Write the parser
server/services/buylistQuoteService.js:
javascript
/**
* Smart Buylist quote engine β Phase 2 of
* docs/superpowers/specs/2026-07-14-smart-buylist-customer-intake-design.md.
*
* parseCardList is a pure function: no database, no plugins. It only
* recognizes the plain-text paste format the merchant UI's textarea uses
* (see the POC). TCGPlayer-app-export and ManaBox CSV-specific parsing are
* explicitly out of scope for this phase (see the plan's Global
* Constraints) β not an oversight.
*/
'use strict';
const VALID_CONDITIONS = ['nm', 'lp', 'mp', 'hp', 'damaged'];
// "<qty> <name> [<SET>] <collector number> (<condition>)" β every field
// after the quantity is optional except the name itself.
const LINE_WITH_SET_RE = /^\s*(?:(\d+)\s*x?\s+)?(.+?)\s*\[([A-Za-z0-9]{2,6})\]\s*([A-Za-z0-9β
-]*)?\s*(?:\(([A-Za-z]{1,10})\))?\s*$/;
// Fallback when there's no bracketed set code at all β name-only, still
// honors a leading quantity and trailing (condition).
const LINE_PLAIN_RE = /^\s*(?:(\d+)\s*x?\s+)?(.+?)\s*(?:\(([A-Za-z]{1,10})\))?\s*$/;
function normalizeCondition(raw) {
if (!raw) return 'nm';
const key = raw.toLowerCase();
return VALID_CONDITIONS.includes(key) ? key : 'nm';
}
function parseCardList(rawText) {
const lines = String(rawText || '')
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.length > 0);
return lines.map((rawLine) => {
const withSet = LINE_WITH_SET_RE.exec(rawLine);
if (withSet) {
const [, qty, name, setCode, collectorNumber, condition] = withSet;
return {
rawLine,
quantity: qty ? Math.max(1, parseInt(qty, 10)) : 1,
name: name.trim(),
setCode: setCode.toUpperCase(),
collectorNumber: collectorNumber || null,
condition: normalizeCondition(condition)
};
}
const plain = LINE_PLAIN_RE.exec(rawLine);
const [, qty, name, condition] = plain;
return {
rawLine,
quantity: qty ? Math.max(1, parseInt(qty, 10)) : 1,
name: (name || rawLine).trim(),
setCode: null,
collectorNumber: null,
condition: normalizeCondition(condition)
};
});
}
module.exports = { parseCardList };- [ ] Step 4: Run the tests to verify they pass
bash
npx vitest run server/services/buylistQuoteService.test.jsExpected: PASS (10 tests)
- [ ] Step 5: Commit
bash
git add server/services/buylistQuoteService.js server/services/buylistQuoteService.test.js
git commit -m "Add a plain-text card list parser for the buylist quote engine"Task 2: Catalog matcher, basis pricing, rate pipeline, and quote composition β
Files:
- Modify:
server/services/buylistQuoteService.js(append to Task 1's file) - Modify:
server/services/buylistQuoteService.test.js(append to Task 1's file)
Interfaces:
Consumes:
parseCardList(Task 1);getPluginfromserver/plugins(getPlugin(game).getProductModel(),.priceSources, throws on unsupported game);getGameBuylistConfig(store, game)fromserver/services/buylistConfigService.js;getPricesForCard/getPricesForPokemonCard/getPricesForRiftboundCard,DEFAULT_CONFIGfromserver/services/priceLookupService.js.Produces (consumed by Task 4's routes):
matchCatalogLine(parsedLine, game, deps) β Promise<{matchStatus:'matched', cardUuid, cardName, setCode, collectorNumber, rarity, finish} | {matchStatus:'unmatched'}>resolveBasisPrice({game, cardUuid, rarity, finish}, priceBasis, deps) β Promise<number|null>applyBuylistRate({basisPrice, rarity, condition, quantity}, buylistConfig) β {offerPrice, ruleApplied, lineTotal}computeOrderTotals(lines, buylistConfig) β {cashTotal, creditTotal, creditBonus}generateQuote({store, game, rawText}) β Promise<{lines: Array<BuylistOrder line shape>}>β the top-level entry point Task 4's create-order route calls._setDeps,_resetDeps
[ ] Step 1: Write the failing tests
Append to server/services/buylistQuoteService.test.js:
javascript
import { matchCatalogLine, resolveBasisPrice, applyBuylistRate, computeOrderTotals, generateQuote, _setDeps, _resetDeps } from './buylistQuoteService.js';
describe('matchCatalogLine', () => {
afterEach(() => _resetDeps());
it('matches by exact set code and collector number', async () => {
const findOne = vi.fn().mockReturnValue({ lean: () => Promise.resolve({
sourceCardUUID: 'uuid-1',
metafields: { card_name: 'Ragavan, Nimble Pilferer', set_code: 'MH2', collector_number: '138', rarity: 'Mythic' },
variants: [{ finish: 'Nonfoil' }, { finish: 'Foil' }]
}) });
const Model = { findOne };
_setDeps({ getPlugin: () => ({ getProductModel: () => Model }) });
const result = await matchCatalogLine(
{ name: 'Ragavan, Nimble Pilferer', setCode: 'MH2', collectorNumber: '138' },
'mtg'
);
expect(findOne).toHaveBeenCalledWith({
'metafields.set_code': 'MH2',
'metafields.collector_number': '138'
});
expect(result).toEqual({
matchStatus: 'matched',
cardUuid: 'uuid-1',
cardName: 'Ragavan, Nimble Pilferer',
setCode: 'MH2',
collectorNumber: '138',
rarity: 'mythic',
finish: 'Nonfoil'
});
});
it('falls back to a case-insensitive exact name match when set/number miss', async () => {
const findOne = vi.fn()
.mockReturnValueOnce({ lean: () => Promise.resolve(null) })
.mockReturnValueOnce({ lean: () => Promise.resolve({
sourceCardUUID: 'uuid-2',
metafields: { card_name: 'Lightning Bolt', set_code: 'CLB', collector_number: '187', rarity: 'Common' },
variants: [{ finish: 'Nonfoil' }]
}) });
_setDeps({ getPlugin: () => ({ getProductModel: () => ({ findOne }) }) });
const result = await matchCatalogLine({ name: 'Lightning Bolt', setCode: 'ZZZ', collectorNumber: '999' }, 'mtg');
expect(result.matchStatus).toBe('matched');
expect(result.rarity).toBe('common');
expect(findOne).toHaveBeenCalledTimes(2);
});
it('returns unmatched when nothing is found', async () => {
const findOne = vi.fn().mockReturnValue({ lean: () => Promise.resolve(null) });
_setDeps({ getPlugin: () => ({ getProductModel: () => ({ findOne }) }) });
const result = await matchCatalogLine({ name: 'Blacc Lotus', setCode: null, collectorNumber: null }, 'mtg');
expect(result).toEqual({ matchStatus: 'unmatched' });
});
});
describe('resolveBasisPrice', () => {
afterEach(() => _resetDeps());
it("'market' mode reads the unrestricted-sourcePriority raw price", async () => {
const getPricesForCard = vi.fn().mockResolvedValue({ _rawPrices: { Nonfoil: 48.50 } });
_setDeps({
getPlugin: () => ({ priceSources: ['tcgplayer', 'cardkingdom'] }),
priceFns: { mtg: getPricesForCard },
DEFAULT_CONFIG: {}
});
const price = await resolveBasisPrice({ game: 'mtg', cardUuid: 'uuid-1', rarity: 'mythic', finish: 'Nonfoil' }, 'market');
expect(price).toBe(48.50);
expect(getPricesForCard).toHaveBeenCalledWith('uuid-1', 'mythic', ['Nonfoil'], {});
});
it("'lowest' mode takes the minimum raw price across sources", async () => {
const getPricesForCard = vi.fn()
.mockResolvedValueOnce({ _rawPrices: { Nonfoil: 5.00 } }) // tcgplayer
.mockResolvedValueOnce({ _rawPrices: { Nonfoil: 3.25 } }); // cardkingdom
_setDeps({
getPlugin: () => ({ priceSources: ['tcgplayer', 'cardkingdom'] }),
priceFns: { mtg: getPricesForCard },
DEFAULT_CONFIG: {}
});
const price = await resolveBasisPrice({ game: 'mtg', cardUuid: 'uuid-1', rarity: 'mythic', finish: 'Nonfoil' }, 'lowest');
expect(price).toBe(3.25);
expect(getPricesForCard).toHaveBeenNthCalledWith(1, 'uuid-1', 'mythic', ['Nonfoil'], { sourcePriority: ['tcgplayer'] });
expect(getPricesForCard).toHaveBeenNthCalledWith(2, 'uuid-1', 'mythic', ['Nonfoil'], { sourcePriority: ['cardkingdom'] });
});
it("'lower_of' mode behaves identically to 'lowest' (documented equivalence)", async () => {
const getPricesForCard = vi.fn()
.mockResolvedValueOnce({ _rawPrices: { Nonfoil: 5.00 } })
.mockResolvedValueOnce({ _rawPrices: { Nonfoil: 3.25 } });
_setDeps({
getPlugin: () => ({ priceSources: ['tcgplayer', 'cardkingdom'] }),
priceFns: { mtg: getPricesForCard },
DEFAULT_CONFIG: {}
});
const price = await resolveBasisPrice({ game: 'mtg', cardUuid: 'uuid-1', rarity: 'mythic', finish: 'Nonfoil' }, 'lower_of');
expect(price).toBe(3.25);
});
it('returns null when no source has a valid price', async () => {
const getPricesForCard = vi.fn().mockResolvedValue({ _rawPrices: { Nonfoil: null } });
_setDeps({
getPlugin: () => ({ priceSources: ['tcgplayer'] }),
priceFns: { mtg: getPricesForCard },
DEFAULT_CONFIG: {}
});
const price = await resolveBasisPrice({ game: 'mtg', cardUuid: 'uuid-1', rarity: 'common', finish: 'Nonfoil' }, 'market');
expect(price).toBeNull();
});
});
describe('applyBuylistRate', () => {
const buylistConfig = {
defaultRate: 0.40,
rarityRates: { mythic: 0.50 },
bulk: { enabled: true, threshold: 0.25, flatPrice: 0.05 },
stopGap: { enabled: true, threshold: 5.00, rate: 0.50 },
conditionMultipliers: { nm: 1.0, lp: 0.85, mp: 0.70, hp: 0.50, damaged: 0.25 }
};
it('applies the rarity-specific rate and condition multiplier when under the stop-gap threshold', () => {
const result = applyBuylistRate({ basisPrice: 2.00, rarity: 'mythic', condition: 'lp', quantity: 1 }, buylistConfig);
// 2.00 * 0.50 (rarity rate) * 0.85 (LP) = 0.85
expect(result.offerPrice).toBe(0.85);
expect(result.ruleApplied).toBe('rarity:mythic');
expect(result.lineTotal).toBe(0.85);
});
it('falls back to the default rate when no rarity override exists', () => {
const result = applyBuylistRate({ basisPrice: 2.00, rarity: 'common', condition: 'nm', quantity: 3 }, buylistConfig);
// 2.00 * 0.40 (default) * 1.0 (NM) = 0.80 per card, x3 quantity
expect(result.offerPrice).toBe(0.80);
expect(result.ruleApplied).toBe('default');
expect(result.lineTotal).toBe(2.40);
});
it('applies the stop-gap rate above its threshold, overriding rarity/default', () => {
const result = applyBuylistRate({ basisPrice: 48.50, rarity: 'mythic', condition: 'nm', quantity: 1 }, buylistConfig);
// 48.50 * 0.50 (stop-gap, overrides the mythic 0.50 rarity rate which is coincidentally equal) * 1.0
expect(result.offerPrice).toBe(24.25);
expect(result.ruleApplied).toBe('stopGap');
});
it('pays a flat bulk price under the bulk threshold, ignoring rate and condition', () => {
const result = applyBuylistRate({ basisPrice: 0.15, rarity: 'common', condition: 'hp', quantity: 12 }, buylistConfig);
expect(result.offerPrice).toBe(0.05);
expect(result.ruleApplied).toBe('bulk');
expect(result.lineTotal).toBe(0.60);
});
it('returns a zero offer with a no-price rule when basisPrice is null', () => {
const result = applyBuylistRate({ basisPrice: null, rarity: 'common', condition: 'nm', quantity: 1 }, buylistConfig);
expect(result.offerPrice).toBe(0);
expect(result.ruleApplied).toBe('no-price');
expect(result.lineTotal).toBe(0);
});
});
describe('computeOrderTotals', () => {
it('sums only included, matched lines and applies the store credit bonus', () => {
const lines = [
{ matchStatus: 'matched', included: true, offerPrice: 24.25, quantity: 1 },
{ matchStatus: 'matched', included: true, offerPrice: 0.80, quantity: 3 },
{ matchStatus: 'matched', included: false, offerPrice: 100, quantity: 1 }, // excluded
{ matchStatus: 'unmatched', included: false, offerPrice: 0, quantity: 1 }
];
const totals = computeOrderTotals(lines, { storeCreditBonus: 0.25 });
// (24.25 + 0.80*3) = 26.65 cash; credit = 26.65 * 1.25 = 33.3125 -> 33.31
expect(totals.cashTotal).toBe(26.65);
expect(totals.creditBonus).toBe(0.25);
expect(totals.creditTotal).toBe(33.31);
});
});
describe('generateQuote', () => {
afterEach(() => _resetDeps());
it('composes parse -> match -> price -> rate for every line', async () => {
const store = { buylistConfig: { mtg: { defaultRate: 0.40, priceBasis: 'market' } } };
_setDeps({
parseCardList: () => [{ rawLine: '1 Ragavan [MH2] 138', quantity: 1, name: 'Ragavan', setCode: 'MH2', collectorNumber: '138', condition: 'nm' }],
matchCatalogLine: async () => ({ matchStatus: 'matched', cardUuid: 'uuid-1', cardName: 'Ragavan, Nimble Pilferer', setCode: 'MH2', collectorNumber: '138', rarity: 'mythic', finish: 'Nonfoil' }),
resolveBasisPrice: async () => 48.50,
applyBuylistRate: () => ({ offerPrice: 24.25, ruleApplied: 'stopGap', lineTotal: 24.25 }),
getGameBuylistConfig: () => ({ defaultRate: 0.40, priceBasis: 'market', storeCreditBonus: 0.25 })
});
const quote = await generateQuote({ store, game: 'mtg', rawText: '1 Ragavan [MH2] 138' });
expect(quote.lines).toHaveLength(1);
expect(quote.lines[0]).toEqual({
rawLine: '1 Ragavan [MH2] 138',
matchStatus: 'matched',
cardUuid: 'uuid-1',
cardName: 'Ragavan, Nimble Pilferer',
setCode: 'MH2',
collectorNumber: '138',
finish: 'Nonfoil',
condition: 'nm',
quantity: 1,
basisPrice: 48.50,
ruleApplied: 'stopGap',
offerPrice: 24.25,
included: true
});
});
it('marks an unmatched line with included:false and no price fields', async () => {
const store = { buylistConfig: {} };
_setDeps({
parseCardList: () => [{ rawLine: 'Blacc Lotus', quantity: 1, name: 'Blacc Lotus', setCode: null, collectorNumber: null, condition: 'nm' }],
matchCatalogLine: async () => ({ matchStatus: 'unmatched' }),
getGameBuylistConfig: () => ({ storeCreditBonus: 0.25 })
});
const quote = await generateQuote({ store, game: 'mtg', rawText: 'Blacc Lotus' });
expect(quote.lines[0]).toEqual({
rawLine: 'Blacc Lotus',
matchStatus: 'unmatched',
quantity: 1,
condition: 'nm',
included: false
});
});
});Add vi to this file's vitest import (import { describe, it, expect, vi, afterEach } from 'vitest';) alongside the existing import from Task 1.
- [ ] Step 2: Run the tests to verify they fail
bash
npx vitest run server/services/buylistQuoteService.test.jsExpected: FAIL β the new exports don't exist yet.
- [ ] Step 3: Implement the matcher, pricing, rate pipeline, and composition
Append to server/services/buylistQuoteService.js (replace the module.exports line at the bottom with the block below, which now exports everything):
javascript
const defaultPlugins = require('../plugins');
const defaultBuylistConfigService = require('./buylistConfigService');
const defaultPriceLookupService = require('./priceLookupService');
let _deps = null;
function _setDeps(deps) { _deps = deps; }
function _resetDeps() { _deps = null; }
function getDeps() {
if (_deps) return _deps;
return {
getPlugin: defaultPlugins.getPlugin,
getGameBuylistConfig: defaultBuylistConfigService.getGameBuylistConfig,
priceFns: {
mtg: defaultPriceLookupService.getPricesForCard,
pokemon: defaultPriceLookupService.getPricesForPokemonCard,
riftbound: defaultPriceLookupService.getPricesForRiftboundCard
},
DEFAULT_CONFIG: defaultPriceLookupService.DEFAULT_CONFIG,
parseCardList,
matchCatalogLine,
resolveBasisPrice,
applyBuylistRate
};
}
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Match target is the game's global "Shopify-ready" catalog
* (plugin.getProductModel()) β NOT a per-store collection, and NOT the
* legacy flat ShopifyMTGProduct model. See the plan's Global Constraints
* for how this was verified.
*/
async function matchCatalogLine(parsedLine, game, deps = getDeps()) {
const plugin = deps.getPlugin(game);
const Model = plugin.getProductModel();
let doc = null;
if (parsedLine.setCode && parsedLine.collectorNumber) {
doc = await Model.findOne({
'metafields.set_code': parsedLine.setCode,
'metafields.collector_number': parsedLine.collectorNumber
}).lean();
}
if (!doc && parsedLine.name) {
doc = await Model.findOne({
'metafields.card_name': new RegExp(`^${escapeRegex(parsedLine.name)}$`, 'i')
}).lean();
}
if (!doc) return { matchStatus: 'unmatched' };
// metafields.rarity is stored title-case ("Mythic"); plugin rarity keys
// are lowercase ("mythic") β verified by direct query, see Global Constraints.
const rarity = (doc.metafields.rarity || 'common').toLowerCase();
// Cards are sorted Nonfoil-first at write time (transformSetsToProducts.js),
// so variants[0] is a safe default "base" finish when the pasted line
// doesn't specify foil/etched/etc.
const variant = doc.variants[0];
return {
matchStatus: 'matched',
cardUuid: doc.sourceCardUUID || doc.sourceCardId,
cardName: doc.metafields.card_name,
setCode: doc.metafields.set_code,
collectorNumber: doc.metafields.collector_number,
rarity,
finish: variant.finish
};
}
/**
* 'market' = first available price in plugin.priceSources order.
* 'lowest' / 'lower_of' = minimum raw price across ALL sources β see the
* plan's Global Constraints for why these two modes are equivalent given
* the current price-data shape (one retail.<finish> number per source,
* no separate "lowest listing" field exists).
*/
async function resolveBasisPrice({ game, cardUuid, rarity, finish }, priceBasis, deps = getDeps()) {
const plugin = deps.getPlugin(game);
const priceFn = deps.priceFns[game];
if (!priceFn) throw new Error(`No price lookup function for game: ${game}`);
if (priceBasis === 'market') {
const prices = await priceFn(cardUuid, rarity, [finish], deps.DEFAULT_CONFIG);
const raw = prices._rawPrices?.[finish];
return typeof raw === 'number' && raw > 0 ? raw : null;
}
const perSourcePrices = await Promise.all(
plugin.priceSources.map(async (source) => {
const prices = await priceFn(cardUuid, rarity, [finish], { sourcePriority: [source] });
return prices._rawPrices?.[finish];
})
);
const validPrices = perSourcePrices.filter((p) => typeof p === 'number' && p > 0);
return validPrices.length ? Math.min(...validPrices) : null;
}
function round2(n) {
return Math.round(n * 100) / 100;
}
/**
* Buylist rate math β NOT priceLookupService's sell-side pipeline.
* buylistConfig has its own conditionMultipliers/bulk/stopGap/rarityRates,
* built in Phase 1 (buylistConfigService.js), separate from pricingConfig.
*/
function applyBuylistRate({ basisPrice, rarity, condition, quantity }, buylistConfig) {
if (basisPrice == null) {
return { offerPrice: 0, ruleApplied: 'no-price', lineTotal: 0 };
}
if (buylistConfig.bulk?.enabled && basisPrice < buylistConfig.bulk.threshold) {
const offerPrice = round2(buylistConfig.bulk.flatPrice);
return { offerPrice, ruleApplied: 'bulk', lineTotal: round2(offerPrice * quantity) };
}
let rate = buylistConfig.rarityRates?.[rarity] ?? buylistConfig.defaultRate;
let ruleApplied = buylistConfig.rarityRates?.[rarity] != null ? `rarity:${rarity}` : 'default';
if (buylistConfig.stopGap?.enabled && basisPrice > buylistConfig.stopGap.threshold) {
rate = buylistConfig.stopGap.rate;
ruleApplied = 'stopGap';
}
const conditionMultiplier = buylistConfig.conditionMultipliers?.[condition] ?? 1.0;
const offerPrice = round2(basisPrice * rate * conditionMultiplier);
return { offerPrice, ruleApplied, lineTotal: round2(offerPrice * quantity) };
}
function computeOrderTotals(lines, buylistConfig) {
const includedLines = lines.filter((l) => l.included && l.matchStatus === 'matched');
const cashTotal = round2(includedLines.reduce((sum, l) => sum + l.offerPrice * l.quantity, 0));
const creditBonus = buylistConfig.storeCreditBonus;
const creditTotal = round2(cashTotal * (1 + creditBonus));
return { cashTotal, creditTotal, creditBonus };
}
/**
* Top-level composition: raw pasted text -> BuylistOrder.lines[] shape.
* Does NOT persist anything β callers (the create-order route) decide
* whether/how to save the result.
*/
async function generateQuote({ store, game, rawText }, deps = getDeps()) {
const buylistConfig = deps.getGameBuylistConfig(store, game);
const parsedLines = deps.parseCardList(rawText);
const lines = await Promise.all(parsedLines.map(async (parsed) => {
const match = await deps.matchCatalogLine(parsed, game, deps);
if (match.matchStatus === 'unmatched') {
return {
rawLine: parsed.rawLine,
matchStatus: 'unmatched',
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',
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
};
}));
return { lines };
}
module.exports = {
parseCardList,
matchCatalogLine,
resolveBasisPrice,
applyBuylistRate,
computeOrderTotals,
generateQuote,
_setDeps,
_resetDeps
};- [ ] Step 4: Run the tests to verify they pass
bash
npx vitest run server/services/buylistQuoteService.test.jsExpected: PASS (23 tests total: 10 from Task 1 + 13 new)
- [ ] Step 5: Commit
bash
git add server/services/buylistQuoteService.js server/services/buylistQuoteService.test.js
git commit -m "Add plugin-driven catalog matching and buylist rate pricing to the quote engine"Task 3: Zod schemas for buylist orders β
Files:
- Create:
server/schemas/buylistOrder.js - Modify:
server/schemas/index.js(add one barrel spread) - Test: append to
server/schemas/schemas.test.js
Interfaces:
Produces:
createBuylistOrderSchema(validatesPOST /api/buylist/ordersbody:{ customer: {email, name?}, rawText, payoutMethod? }),updateBuylistOrderLinesSchema(validatesPATCH /api/buylist/orders/:idbody:{ lines: [{included, offerPrice}], payoutMethod? }). Both consumed by Task 4/5's routes.[ ] Step 1: Write the failing tests
Append to server/schemas/schemas.test.js (add the two new schema names to the existing barrel import at the top of the file):
javascript
describe('createBuylistOrderSchema', () => {
it('accepts a valid create body', () => {
const result = createBuylistOrderSchema.safeParse({
customer: { email: 'jamie@example.com', name: 'Jamie K.' },
rawText: '1 Ragavan, Nimble Pilferer [MH2] 138'
});
expect(result.success).toBe(true);
});
it('accepts an optional payoutMethod', () => {
expect(createBuylistOrderSchema.safeParse({
customer: { email: 'a@b.com' },
rawText: '1 Lightning Bolt [CLB] 187',
payoutMethod: 'cash'
}).success).toBe(true);
});
it('rejects a missing customer email', () => {
expect(createBuylistOrderSchema.safeParse({
customer: {},
rawText: '1 Lightning Bolt [CLB] 187'
}).success).toBe(false);
});
it('rejects an invalid email', () => {
expect(createBuylistOrderSchema.safeParse({
customer: { email: 'not-an-email' },
rawText: '1 Lightning Bolt [CLB] 187'
}).success).toBe(false);
});
it('rejects blank rawText', () => {
expect(createBuylistOrderSchema.safeParse({
customer: { email: 'a@b.com' },
rawText: ' '
}).success).toBe(false);
});
it('rejects an invalid payoutMethod', () => {
expect(createBuylistOrderSchema.safeParse({
customer: { email: 'a@b.com' },
rawText: '1 Lightning Bolt [CLB] 187',
payoutMethod: 'bitcoin'
}).success).toBe(false);
});
});
describe('updateBuylistOrderLinesSchema', () => {
it('accepts a valid lines update', () => {
const result = updateBuylistOrderLinesSchema.safeParse({
lines: [{ included: true, offerPrice: 24.25 }, { included: false, offerPrice: 0 }]
});
expect(result.success).toBe(true);
});
it('accepts an optional payoutMethod change', () => {
expect(updateBuylistOrderLinesSchema.safeParse({
lines: [{ included: true, offerPrice: 5 }],
payoutMethod: 'store_credit'
}).success).toBe(true);
});
it('rejects an empty lines array', () => {
expect(updateBuylistOrderLinesSchema.safeParse({ lines: [] }).success).toBe(false);
});
it('rejects a negative offerPrice', () => {
expect(updateBuylistOrderLinesSchema.safeParse({
lines: [{ included: true, offerPrice: -1 }]
}).success).toBe(false);
});
it('rejects an unknown top-level key (strict)', () => {
expect(updateBuylistOrderLinesSchema.safeParse({
lines: [{ included: true, offerPrice: 1 }],
surprise: true
}).success).toBe(false);
});
});- [ ] Step 2: Run the tests to verify they fail
bash
npx vitest run server/schemas/schemas.test.jsExpected: FAIL β the two schemas aren't exported yet.
- [ ] Step 3: Write the schema file and register it
server/schemas/buylistOrder.js:
javascript
/**
* Buylist order schemas.
*
* Validates POST /api/buylist/orders (create from a pasted list) and
* PATCH /api/buylist/orders/:id (merchant edits to lines/payout) bodies.
*/
'use strict';
const { z } = require('zod');
const createBuylistOrderSchema = z.object({
customer: z.object({
email: z.string().email('customer.email must be a valid email'),
name: z.string().optional()
}),
rawText: z.string().trim().min(1, 'rawText must not be blank'),
payoutMethod: z.enum(['store_credit', 'cash']).optional()
}).strict();
const updateBuylistOrderLinesSchema = z.object({
lines: z.array(z.object({
included: z.boolean(),
offerPrice: z.number().min(0, 'offerPrice must be >= 0')
})).min(1, 'lines must not be empty'),
payoutMethod: z.enum(['store_credit', 'cash']).optional()
}).strict();
module.exports = { createBuylistOrderSchema, updateBuylistOrderLinesSchema };In server/schemas/index.js, after the // Buylist / ...require('./buylistConfig'), line, add:
javascript
...require('./buylistOrder'),- [ ] Step 4: Run the tests to verify they pass
bash
npx vitest run server/schemas/schemas.test.jsExpected: PASS (existing count + 11)
- [ ] Step 5: Commit
bash
git add server/schemas/buylistOrder.js server/schemas/index.js server/schemas/schemas.test.js
git commit -m "Validate buylist order create and line-edit requests"Task 4: Create and list buylist orders β
Files:
- Modify:
server/routes/api.js(insert directly after thePUT /buylist/confighandler from Phase 1)
Interfaces:
Consumes:
generateQuote,computeOrderTotals(Task 2);createBuylistOrderSchema(Task 3);getGameBuylistConfig(Phase 1);BuylistOrdermodel (Phase 1);req.store/req.query.gamepattern already established by/buylist/config.Produces:
POST /api/buylist/orders?game=<id>β creates and returns aBuylistOrder;GET /api/buylist/orders?game=<id>&status=&page=&limit=β paginated list. Task 8's client Orders tab and Task 9's New Buy-In tab call these.[ ] Step 1: Add the routes
Insert after Phase 1's PUT /buylist/config handler in server/routes/api.js:
javascript
/**
* POST /api/buylist/orders?game=<id>
* Create a buy order from a pasted card list: parse -> match -> price ->
* rate, then persist. game is REQUIRED β no default (CLAUDE.md Β§5.5).
*/
router.post('/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: `Missing or unsupported game: ${game}` });
}
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, computeOrderTotals } = require('../services/buylistQuoteService');
const { getGameBuylistConfig } = require('../services/buylistConfigService');
const buylistConfig = getGameBuylistConfig(req.store, game);
const quote = await generateQuote({ store: req.store, game, rawText: parsed.data.rawText });
const totals = computeOrderTotals(quote.lines, buylistConfig);
const BuylistOrder = require('../models/BuylistOrder');
const order = new BuylistOrder({
shop: req.store.shop,
game,
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
}
});
await order.save();
logger.info('Created buylist order', { shop: req.store.shop, game, 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' });
}
});
/**
* GET /api/buylist/orders?game=<id>&status=&page=&limit=
* List this store's buy orders, newest first, optionally filtered by status.
*/
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: `Missing or 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, 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' });
}
});- [ ] Step 2: Run the full server suite
bash
npm test 2>&1 | tail -5Expected: all passing (no route-level test file β matches the repo's existing pattern for api.js handlers; covered indirectly via schema + service tests).
- [ ] Step 3: Commit
bash
git add server/routes/api.js
git commit -m "Add endpoints to create and list buylist orders"Task 5: Fetch and edit a single buylist order β
Files:
- Modify:
server/routes/api.js(insert directly after Task 4's routes)
Interfaces:
Consumes:
updateBuylistOrderLinesSchema(Task 3);computeOrderTotals(Task 2);getGameBuylistConfig.Produces:
GET /api/buylist/orders/:id,PATCH /api/buylist/orders/:id. Task 10's Review screen calls both.[ ] Step 1: Add the routes
javascript
/**
* GET /api/buylist/orders/:id
* Fetch a single buy order. Scoped to req.store.shop β a store can never
* read another store's order by guessing an id.
*/
router.get('/buylist/orders/:id', async (req, res) => {
try {
const BuylistOrder = require('../models/BuylistOrder');
const order = await BuylistOrder.findOne({ _id: req.params.id, shop: req.store.shop }).lean();
if (!order) {
return res.status(404).json({ error: 'Buy order not found' });
}
res.json({ order });
} catch (error) {
logger.error('Failed to fetch buylist order', { error: error.message, orderId: req.params.id });
res.status(500).json({ error: 'Failed to fetch buylist order' });
}
});
/**
* PATCH /api/buylist/orders/:id
* Merchant edits to a pending order's lines (offer price, include/exclude)
* and/or payout method. Recomputes payout totals from the submitted line
* values β does not re-run pricing rules (a merchant override is final).
* Only 'pending' orders are editable.
*/
router.patch('/buylist/orders/:id', async (req, res) => {
try {
const { updateBuylistOrderLinesSchema } = require('../schemas');
const parsed = updateBuylistOrderLinesSchema.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 BuylistOrder = require('../models/BuylistOrder');
const order = await BuylistOrder.findOne({ _id: req.params.id, shop: req.store.shop });
if (!order) {
return res.status(404).json({ error: 'Buy order not found' });
}
if (order.status !== 'pending') {
return res.status(409).json({ error: `Cannot edit an order with status ${order.status}` });
}
if (parsed.data.lines.length !== order.lines.length) {
return res.status(400).json({ error: 'lines length must match the stored order' });
}
parsed.data.lines.forEach((edit, i) => {
order.lines[i].included = edit.included;
order.lines[i].offerPrice = edit.offerPrice;
});
if (parsed.data.payoutMethod) {
order.payout.method = parsed.data.payoutMethod;
}
const { computeOrderTotals } = require('../services/buylistQuoteService');
const { getGameBuylistConfig } = require('../services/buylistConfigService');
const buylistConfig = getGameBuylistConfig(req.store, order.game);
const totals = computeOrderTotals(order.lines, buylistConfig);
order.payout.cashTotal = totals.cashTotal;
order.payout.creditTotal = totals.creditTotal;
order.payout.creditBonus = totals.creditBonus;
await order.save();
res.json({ order });
} catch (error) {
logger.error('Failed to update buylist order', { error: error.message, orderId: req.params.id });
res.status(500).json({ error: 'Failed to update buylist order' });
}
});- [ ] Step 2: Run the full server suite
bash
npm test 2>&1 | tail -5Expected: all passing
- [ ] Step 3: Commit
bash
git add server/routes/api.js
git commit -m "Let merchants view and edit a pending buylist order's lines"Task 6: Accept and decline a buylist order β
Files:
- Modify:
server/routes/api.js(insert directly after Task 5's routes)
Interfaces:
Produces:
POST /api/buylist/orders/:id/accept,POST /api/buylist/orders/:id/decline. Task 10's Review screen calls both.[ ] Step 1: Add the routes
javascript
/**
* POST /api/buylist/orders/:id/accept
* Merchant finalizes the offer. Only a 'pending' order can be accepted.
*/
router.post('/buylist/orders/:id/accept', async (req, res) => {
try {
const BuylistOrder = require('../models/BuylistOrder');
const order = await BuylistOrder.findOne({ _id: req.params.id, shop: req.store.shop });
if (!order) {
return res.status(404).json({ error: 'Buy order not found' });
}
if (order.status !== 'pending') {
return res.status(409).json({ error: `Cannot accept an order with status ${order.status}` });
}
order.status = 'accepted';
await order.save();
logger.info('Accepted buylist order', { shop: req.store.shop, orderId: order._id });
res.json({ order });
} catch (error) {
logger.error('Failed to accept buylist order', { error: error.message, orderId: req.params.id });
res.status(500).json({ error: 'Failed to accept buylist order' });
}
});
/**
* POST /api/buylist/orders/:id/decline
* Only a 'pending' order can be declined.
*/
router.post('/buylist/orders/:id/decline', async (req, res) => {
try {
const BuylistOrder = require('../models/BuylistOrder');
const order = await BuylistOrder.findOne({ _id: req.params.id, shop: req.store.shop });
if (!order) {
return res.status(404).json({ error: 'Buy order not found' });
}
if (order.status !== 'pending') {
return res.status(409).json({ error: `Cannot decline an order with status ${order.status}` });
}
order.status = 'declined';
await order.save();
logger.info('Declined buylist order', { shop: req.store.shop, orderId: order._id });
res.json({ order });
} catch (error) {
logger.error('Failed to decline buylist order', { error: error.message, orderId: req.params.id });
res.status(500).json({ error: 'Failed to decline buylist order' });
}
});- [ ] Step 2: Run the full server suite
bash
npm test 2>&1 | tail -5Expected: all passing
- [ ] Step 3: Manual end-to-end check (requires
docker compose up -d+ a running dev server)
bash
node -e "
const mongoose = require('mongoose');
(async () => {
await mongoose.connect('mongodb://localhost:27017/shopify_test');
const { generateQuote, computeOrderTotals } = require('./server/services/buylistQuoteService');
const Store = require('./server/models/Store');
const { applyGameBuylistConfigUpdate, getGameBuylistConfig } = require('./server/services/buylistConfigService');
const store = new Store({ shop: 'e2e-quote-check.myshopify.com', accessToken: 't', scope: 'read_products' });
applyGameBuylistConfigUpdate(store, 'mtg', { enabled: true });
const quote = await generateQuote({ store, game: 'mtg', rawText: '1 Lightning Bolt [CLB] 187\n1 Not A Real Card' });
console.log(JSON.stringify(quote, null, 2));
console.log(computeOrderTotals(quote.lines, getGameBuylistConfig(store, 'mtg')));
await mongoose.disconnect();
})();
"Expected: the Lightning Bolt line resolves matchStatus: 'matched' with a non-null basisPrice/offerPrice; the fake card resolves matchStatus: 'unmatched'.
- [ ] Step 4: Commit
bash
git add server/routes/api.js
git commit -m "Add accept and decline actions for pending buylist orders"Task 7: Restructure the Buylist page into a tab shell β
Files:
- Create:
client/src/pages/buylist/BuylistSettingsTab.jsx(Phase 1'sBuylistPage.jsxbody, unchanged, extracted verbatim) - Modify:
client/src/pages/buylist/BuylistPage.jsx(becomes a thin tab shell) - Create:
client/src/pages/buylist/BuylistSettingsTab.test.jsx(Phase 1'sBuylistPage.test.jsx, retargeted) - Modify:
client/src/pages/buylist/BuylistPage.test.jsx(now tests the tab shell itself)
Interfaces:
- Produces: default-exported
BuylistSettingsTab(identical behavior to Phase 1's page);BuylistPagebecomes a 3-tab shell (Buy Orders | New Buy-In | Settings) that Task 8/9 add real content to (this task's shell renders placeholder text for the two new tabs, wired up fully by Tasks 8β9).
This is a pure refactor β no behavior change to the settings functionality. Verify via the existing (moved) test file passing unchanged, plus a new shell-level test.
- [ ] Step 1: Extract the settings component
Copy the entire current content of client/src/pages/buylist/BuylistPage.jsx into a new file client/src/pages/buylist/BuylistSettingsTab.jsx, with exactly one change: rename the exported function from BuylistPage to BuylistSettingsTab (both the export default function line and nothing else β every other line, including all imports, RateField, hasRunOnSuffixCollision, and the full JSX body, is copied verbatim).
Copy client/src/pages/buylist/BuylistPage.test.jsx to client/src/pages/buylist/BuylistSettingsTab.test.jsx, updating only the import line:
javascript
import BuylistSettingsTab from './BuylistSettingsTab';and every render(<BuylistPage />) call site to render(<BuylistSettingsTab />). No other change.
- [ ] Step 2: Run the moved test file to confirm it passes unchanged
bash
npx vitest run --config vitest.client.config.js client/src/pages/buylist/BuylistSettingsTab.test.jsxExpected: PASS (same count as the old BuylistPage.test.jsx β 7 tests per the Phase 1 history)
- [ ] Step 3: Write the failing shell test
Replace the content of client/src/pages/buylist/BuylistPage.test.jsx entirely with:
jsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import BuylistPage from './BuylistPage';
vi.mock('./BuylistSettingsTab', () => ({ default: () => <div>Settings tab content</div> }));
vi.mock('./BuylistOrdersTab', () => ({ default: () => <div>Orders tab content</div> }));
vi.mock('./NewBuyInTab', () => ({ default: () => <div>New buy-in tab content</div> }));
describe('BuylistPage tab shell', () => {
it('shows the Buy Orders tab by default', () => {
render(<BuylistPage />);
expect(screen.getByText('Orders tab content')).toBeInTheDocument();
});
it('switches to New Buy-In when clicked', () => {
render(<BuylistPage />);
fireEvent.click(screen.getByRole('tab', { name: /new buy-in/i }));
expect(screen.getByText('New buy-in tab content')).toBeInTheDocument();
});
it('switches to Settings when clicked', () => {
render(<BuylistPage />);
fireEvent.click(screen.getByRole('tab', { name: /settings/i }));
expect(screen.getByText('Settings tab content')).toBeInTheDocument();
});
});- [ ] Step 4: Run to verify it fails
bash
npx vitest run --config vitest.client.config.js client/src/pages/buylist/BuylistPage.test.jsxExpected: FAIL β BuylistPage still renders the old settings content directly, no tabs exist yet, and ./BuylistOrdersTab/./NewBuyInTab don't exist for the mock to target.
- [ ] Step 5: Rewrite BuylistPage.jsx as a tab shell
Replace the entire content of client/src/pages/buylist/BuylistPage.jsx with:
jsx
import { useState } from 'react';
import BuylistOrdersTab from './BuylistOrdersTab';
import NewBuyInTab from './NewBuyInTab';
import BuylistSettingsTab from './BuylistSettingsTab';
const TABS = [
{ key: 'orders', label: 'Buy Orders', Component: BuylistOrdersTab },
{ key: 'new', label: 'New Buy-In', Component: NewBuyInTab },
{ key: 'settings', label: 'Settings', Component: BuylistSettingsTab }
];
export default function BuylistPage() {
const [activeTab, setActiveTab] = useState('orders');
const active = TABS.find((t) => t.key === activeTab);
const ActiveComponent = active.Component;
return (
<div className="p-6 space-y-6">
<div role="tablist" className="flex gap-2 border-b-2 border-black pb-2">
{TABS.map((t) => (
<button
key={t.key}
role="tab"
aria-selected={t.key === activeTab}
className={`px-3 py-1.5 font-semibold text-sm rounded ${
t.key === activeTab ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'
}`}
onClick={() => setActiveTab(t.key)}
>
{t.label}
</button>
))}
</div>
<ActiveComponent />
</div>
);
}This will still fail Step 4's test at this point because BuylistOrdersTab.jsx/NewBuyInTab.jsx don't exist yet β create minimal stub files so the shell can import them (Tasks 8β9 flesh these out):
client/src/pages/buylist/BuylistOrdersTab.jsx:
jsx
export default function BuylistOrdersTab() {
return <div>Buy Orders tab β built in Task 8</div>;
}client/src/pages/buylist/NewBuyInTab.jsx:
jsx
export default function NewBuyInTab() {
return <div>New Buy-In tab β built in Task 9</div>;
}- [ ] Step 6: Run the shell test to verify it passes
bash
npx vitest run --config vitest.client.config.js client/src/pages/buylist/BuylistPage.test.jsxExpected: PASS (3 tests)
- [ ] Step 7: Run the full client suite and build
bash
npm run test:client 2>&1 | tail -5
npm run build 2>&1 | tail -3Expected: all passing, build exit 0
- [ ] Step 8: Commit
bash
git add client/src/pages/buylist/
git commit -m "Restructure the Buylist page into Buy Orders, New Buy-In, and Settings tabs"Task 8: Buy Orders queue tab β
Files:
- Modify:
client/src/pages/buylist/BuylistOrdersTab.jsx(replace Task 7's stub) - Create:
client/src/pages/buylist/BuylistOrdersTab.test.jsx
Interfaces:
Consumes:
GET /buylist/config(Phase 1, for the game tabs);GET /buylist/orders?game=(Task 4).Produces: default-exported
BuylistOrdersTab. Consumed byBuylistPage(Task 7, already wired).[ ] Step 1: Write the failing test
client/src/pages/buylist/BuylistOrdersTab.test.jsx:
jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import BuylistOrdersTab from './BuylistOrdersTab';
vi.mock('../../utils/api', () => ({ default: { get: vi.fn() } }));
import api from '../../utils/api';
const gamesResponse = { data: { games: [{ id: 'mtg', name: 'Magic: The Gathering' }] } };
const ordersResponse = {
data: {
orders: [
{
_id: 'order-1',
customer: { email: 'jamie@example.com' },
game: 'mtg',
status: 'pending',
lines: [{ included: true }, { included: true }],
payout: { method: 'store_credit', cashTotal: 24.25, creditTotal: 30.31 },
createdAt: '2026-07-16T12:00:00Z'
}
],
pagination: { page: 1, limit: 20, total: 1, pages: 1 }
}
};
beforeEach(() => {
vi.clearAllMocks();
api.get.mockImplementation((url) =>
url.startsWith('/games') ? Promise.resolve(gamesResponse) : Promise.resolve(ordersResponse)
);
});
function renderWithRouter(ui) {
return render(<MemoryRouter>{ui}</MemoryRouter>);
}
describe('BuylistOrdersTab', () => {
it('lists orders for the selected game', async () => {
renderWithRouter(<BuylistOrdersTab />);
await waitFor(() => expect(api.get).toHaveBeenCalledWith(expect.stringContaining('/buylist/orders?game=mtg')));
expect(await screen.findByText('jamie@example.com')).toBeInTheDocument();
expect(screen.getByText(/pending/i)).toBeInTheDocument();
});
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');
});
});- [ ] Step 2: Run to verify it fails
bash
npx vitest run --config vitest.client.config.js client/src/pages/buylist/BuylistOrdersTab.test.jsxExpected: FAIL β the stub component renders none of this.
- [ ] Step 3: Implement the tab
client/src/pages/buylist/BuylistOrdersTab.jsx:
jsx
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import api from '../../utils/api';
import { Badge, Text } from '../../components/retroui';
const STATUS_LABEL = {
pending: 'Pending review',
offer_sent: 'Offer sent',
accepted: 'Accepted',
declined: 'Declined',
paid: 'Paid'
};
export default function BuylistOrdersTab() {
const [games, setGames] = useState([]);
const [game, setGame] = useState('mtg');
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);
api.get(`/buylist/orders?game=${game}`)
.then(({ data }) => { if (!ignore) setOrders(data.orders || []); })
.catch(() => { if (!ignore) setOrders([]); })
.finally(() => { if (!ignore) setLoading(false); });
return () => { ignore = true; };
}, [game]);
return (
<div className="space-y-4">
<div className="flex gap-2">
{games.map((g) => (
<button
key={g.id}
className={`px-3 py-1 text-sm font-semibold rounded border-2 border-black ${g.id === game ? 'bg-accent' : 'bg-card'}`}
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">Lines</th>
<th className="px-4 py-2 text-left">Payout</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">{order.lines.length}</td>
<td className="px-4 py-2">
{order.payout.method === 'store_credit' ? order.payout.creditTotal : order.payout.cashTotal}
</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 to verify it passes
bash
npx vitest run --config vitest.client.config.js client/src/pages/buylist/BuylistOrdersTab.test.jsxExpected: PASS (2 tests)
- [ ] Step 5: Commit
bash
git add client/src/pages/buylist/BuylistOrdersTab.jsx client/src/pages/buylist/BuylistOrdersTab.test.jsx
git commit -m "Show the buy orders queue with a link into each order's review page"Task 9: New Buy-In import tab β
Files:
- Modify:
client/src/pages/buylist/NewBuyInTab.jsx(replace Task 7's stub) - Create:
client/src/pages/buylist/NewBuyInTab.test.jsx
Interfaces:
Consumes:
POST /buylist/orders?game=(Task 4).Produces: default-exported
NewBuyInTab. On success, navigates to/buylist/orders/:id(Task 10's Review screen).[ ] Step 1: Write the failing test
client/src/pages/buylist/NewBuyInTab.test.jsx:
jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import NewBuyInTab from './NewBuyInTab';
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return { ...actual, useNavigate: () => mockNavigate };
});
vi.mock('../../utils/api', () => ({ default: { get: vi.fn(), post: vi.fn() } }));
import api from '../../utils/api';
beforeEach(() => {
vi.clearAllMocks();
api.get.mockResolvedValue({ data: { games: [{ id: 'mtg', name: 'Magic: The Gathering' }] } });
api.post.mockResolvedValue({ data: { order: { _id: 'order-1' } } });
});
describe('NewBuyInTab', () => {
it('submits the pasted list and navigates to the new order', async () => {
render(<MemoryRouter><NewBuyInTab /></MemoryRouter>);
fireEvent.change(await screen.findByLabelText(/customer email/i), { target: { value: 'jamie@example.com' } });
fireEvent.change(screen.getByLabelText(/card list/i), { target: { value: '1 Lightning Bolt [CLB] 187' } });
fireEvent.click(screen.getByRole('button', { name: /generate offer/i }));
await waitFor(() => expect(api.post).toHaveBeenCalledWith(
'/buylist/orders?game=mtg',
{ customer: { email: 'jamie@example.com' }, rawText: '1 Lightning Bolt [CLB] 187' }
));
expect(mockNavigate).toHaveBeenCalledWith('/buylist/orders/order-1');
});
it('shows an error message when the request fails', async () => {
api.post.mockRejectedValue({ response: { data: { error: 'Buylist is not enabled for this game' } } });
render(<MemoryRouter><NewBuyInTab /></MemoryRouter>);
fireEvent.change(await screen.findByLabelText(/customer email/i), { target: { value: 'jamie@example.com' } });
fireEvent.change(screen.getByLabelText(/card list/i), { target: { value: '1 Lightning Bolt [CLB] 187' } });
fireEvent.click(screen.getByRole('button', { name: /generate offer/i }));
expect(await screen.findByText('Buylist is not enabled for this game')).toBeInTheDocument();
});
});- [ ] Step 2: Run to verify it fails
bash
npx vitest run --config vitest.client.config.js client/src/pages/buylist/NewBuyInTab.test.jsxExpected: FAIL β the stub renders none of this.
- [ ] Step 3: Implement the tab
client/src/pages/buylist/NewBuyInTab.jsx:
jsx
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../../utils/api';
import { Alert, Button, Input, Text, Textarea } from '../../components/retroui';
export default function NewBuyInTab() {
const navigate = useNavigate();
const [games, setGames] = useState([]);
const [game, setGame] = useState('mtg');
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const [rawText, setRawText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
api.get('/games').then(({ data }) => setGames(data.games || [])).catch(() => setGames([]));
}, []);
const submit = async () => {
setSubmitting(true);
setError('');
try {
const customer = name ? { email, name } : { email };
const { data } = await api.post(`/buylist/orders?game=${game}`, { customer, rawText });
navigate(`/buylist/orders/${data.order._id}`);
} catch (err) {
setError(err.response?.data?.error || 'Failed to generate the offer');
} finally {
setSubmitting(false);
}
};
return (
<div className="space-y-4 max-w-2xl">
<Text as="h2">New Buy-In</Text>
<p className="text-muted-foreground">Paste the customer's card list to generate an offer.</p>
{error && <Alert status="error">{error}</Alert>}
<div className="flex gap-2">
{games.map((g) => (
<button
key={g.id}
className={`px-3 py-1 text-sm font-semibold rounded border-2 border-black ${g.id === game ? 'bg-accent' : 'bg-card'}`}
onClick={() => setGame(g.id)}
>
{g.name}
</button>
))}
</div>
<div>
<label htmlFor="customer-email" className="font-semibold text-sm block mb-1">Customer email</label>
<Input id="customer-email" aria-label="Customer email" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div>
<label htmlFor="customer-name" className="font-semibold text-sm block mb-1">Customer name (optional)</label>
<Input id="customer-name" aria-label="Customer name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<label htmlFor="card-list" className="font-semibold text-sm block mb-1">Card list</label>
<Textarea
id="card-list"
aria-label="Card list"
rows={10}
value={rawText}
onChange={(e) => setRawText(e.target.value)}
placeholder={'1 Ragavan, Nimble Pilferer [MH2] 138\n4 Orcish Bowmasters [LTR] 103'}
/>
</div>
<Button onClick={submit} disabled={submitting || !email || !rawText}>
{submitting ? 'Generatingβ¦' : 'Generate Offer'}
</Button>
</div>
);
}- [ ] Step 4: Run to verify it passes
bash
npx vitest run --config vitest.client.config.js client/src/pages/buylist/NewBuyInTab.test.jsxExpected: PASS (2 tests)
- [ ] Step 5: Commit
bash
git add client/src/pages/buylist/NewBuyInTab.jsx client/src/pages/buylist/NewBuyInTab.test.jsx
git commit -m "Add the New Buy-In form for generating an offer from a pasted card list"Task 10: Review screen and route β
Files:
- Create:
client/src/pages/buylist/BuylistReviewPage.jsx - Create:
client/src/pages/buylist/BuylistReviewPage.test.jsx - Modify:
client/src/App.jsx(add the/buylist/orders/:idroute)
Interfaces:
Consumes:
GET /buylist/orders/:id,PATCH /buylist/orders/:id,POST /buylist/orders/:id/accept,POST /buylist/orders/:id/decline(Tasks 4β6).Produces: default-exported
BuylistReviewPage, routed at/buylist/orders/:id.[ ] Step 1: Write the failing test
client/src/pages/buylist/BuylistReviewPage.test.jsx:
jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import BuylistReviewPage from './BuylistReviewPage';
vi.mock('../../utils/api', () => ({ default: { get: vi.fn(), patch: vi.fn(), post: vi.fn() } }));
import api from '../../utils/api';
const baseOrder = {
_id: 'order-1',
game: 'mtg',
status: 'pending',
customer: { email: 'jamie@example.com' },
lines: [
{ rawLine: '1 Lightning Bolt [CLB] 187', matchStatus: 'matched', cardName: 'Lightning Bolt', quantity: 1, offerPrice: 0.80, included: true },
{ rawLine: 'Blacc Lotus', matchStatus: 'unmatched', quantity: 1, offerPrice: 0, included: false }
],
payout: { method: 'store_credit', cashTotal: 0.80, creditTotal: 1.00, creditBonus: 0.25 }
};
function renderAt(id) {
return render(
<MemoryRouter initialEntries={[`/buylist/orders/${id}`]}>
<Routes>
<Route path="/buylist/orders/:id" element={<BuylistReviewPage />} />
</Routes>
</MemoryRouter>
);
}
beforeEach(() => {
vi.clearAllMocks();
api.get.mockResolvedValue({ data: { order: baseOrder } });
});
describe('BuylistReviewPage', () => {
it('loads and displays the order lines and totals', async () => {
renderAt('order-1');
expect(await screen.findByText('Lightning Bolt')).toBeInTheDocument();
expect(screen.getByText('jamie@example.com')).toBeInTheDocument();
expect(screen.getByText(/1\.00/)).toBeInTheDocument();
});
it('accepts the order', async () => {
api.post.mockResolvedValue({ data: { order: { ...baseOrder, status: 'accepted' } } });
renderAt('order-1');
await screen.findByText('Lightning Bolt');
fireEvent.click(screen.getByRole('button', { name: /accept/i }));
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/buylist/orders/order-1/accept'));
expect(await screen.findByText(/accepted/i)).toBeInTheDocument();
});
it('declines the order', async () => {
api.post.mockResolvedValue({ data: { order: { ...baseOrder, status: 'declined' } } });
renderAt('order-1');
await screen.findByText('Lightning Bolt');
fireEvent.click(screen.getByRole('button', { name: /decline/i }));
await waitFor(() => expect(api.post).toHaveBeenCalledWith('/buylist/orders/order-1/decline'));
expect(await screen.findByText(/declined/i)).toBeInTheDocument();
});
});- [ ] Step 2: Run to verify it fails
bash
npx vitest run --config vitest.client.config.js client/src/pages/buylist/BuylistReviewPage.test.jsxExpected: FAIL β Cannot find module './BuylistReviewPage'
- [ ] Step 3: Implement the page
client/src/pages/buylist/BuylistReviewPage.jsx:
jsx
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import api from '../../utils/api';
import { Alert, Badge, Button, Text } from '../../components/retroui';
const STATUS_LABEL = {
pending: 'Pending review',
offer_sent: 'Offer sent',
accepted: 'Accepted',
declined: 'Declined',
paid: 'Paid'
};
export default function BuylistReviewPage() {
const { id } = useParams();
const [order, setOrder] = useState(null);
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]);
const act = async (action) => {
setActing(true);
setError('');
try {
const { data } = await api.post(`/buylist/orders/${id}/${action}`);
setOrder(data.order);
} catch (err) {
setError(err.response?.data?.error || `Failed to ${action} the order`);
} finally {
setActing(false);
}
};
if (error && !order) return <Alert status="error">{error}</Alert>;
if (!order) return <Text>Loading orderβ¦</Text>;
const isPending = order.status === 'pending';
return (
<div className="p-6 space-y-4 max-w-3xl">
<div className="flex items-center justify-between">
<div>
<Text as="h1">Buy Order</Text>
<p className="text-muted-foreground">{order.customer.email}</p>
</div>
<Badge>{STATUS_LABEL[order.status] || order.status}</Badge>
</div>
{error && <Alert status="error">{error}</Alert>}
<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">Card</th>
<th className="px-4 py-2 text-left">Qty</th>
<th className="px-4 py-2 text-left">Offer</th>
<th className="px-4 py-2 text-left">Status</th>
</tr>
</thead>
<tbody>
{order.lines.map((line, i) => (
<tr key={i} className="border-b border-black/20">
<td className="px-4 py-2">{line.cardName || line.rawLine}</td>
<td className="px-4 py-2">{line.quantity}</td>
<td className="px-4 py-2">{line.offerPrice}</td>
<td className="px-4 py-2">
{line.matchStatus === 'unmatched' ? <Badge>No match</Badge> : (line.included ? 'Included' : 'Excluded')}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="border-t-2 border-black pt-3 font-semibold">
{order.payout.method === 'store_credit'
? `Store credit total: ${order.payout.creditTotal}`
: `Cash total: ${order.payout.cashTotal}`}
</div>
{isPending && (
<div className="flex gap-2">
<Button onClick={() => act('accept')} disabled={acting}>Accept & Send Offer</Button>
<Button variant="destructive" onClick={() => act('decline')} disabled={acting}>Decline Order</Button>
</div>
)}
</div>
);
}Adaptation note: if Button's variant prop doesn't include destructive (check client/src/components/retroui/Button.jsx's cva variants before assuming), use whichever variant name that file actually declares for a discouraging/negative action β match the real component, don't guess.
- [ ] Step 4: Wire the route
In client/src/App.jsx, import BuylistReviewPage alongside BuylistPage's existing import, and add directly after the /buylist route:
jsx
<Route path="/buylist/orders/:id" element={wrap(<BuylistReviewPage />)} />- [ ] Step 5: Run to verify it passes, then the full client suite and build
bash
npx vitest run --config vitest.client.config.js client/src/pages/buylist/BuylistReviewPage.test.jsx
npm run test:client 2>&1 | tail -5
npm run build 2>&1 | tail -3Expected: PASS (3 tests) / all passing / build exit 0
- [ ] Step 6: Commit
bash
git add client/src/pages/buylist/BuylistReviewPage.jsx client/src/pages/buylist/BuylistReviewPage.test.jsx client/src/App.jsx
git commit -m "Add the buy order review screen with accept and decline actions"Task 11: Quality bars and PR β
Files: none new
- [ ] Step 1: Run the full Β§7 checklist
bash
npm test # exit 0
npm run test:client # exit 0
npm run test:coverage # every modified file β₯70% on all four metrics
npm run lint # exit 0, zero new warnings
npm run build # exit 0
git diff origin/main | grep -n "|| 'mtg'\|?? 'mtg'" # zero matches (Β§5.5)
grep -rn "myshopify.com" server/services/buylistQuoteService.js server/schemas/buylistOrder.js # only test fixtures- [ ] Step 2: Manual end-to-end check in the dev store
bash
docker compose up -d
npm run dev:no-workerIn the browser: Tools β Buylist β New Buy-In β paste a few real lines (mix of a real card and a nonsense name) for MTG, submit, confirm it lands on the Review screen with correct matched/unmatched lines and a nonzero total; go back to Buy Orders and confirm the new order appears; click Accept and confirm the status updates. Repeat once for Pokemon to confirm plugin-driven matching works for both games (game parity check, Β§5.1).
- [ ] Step 3: Open the PR
Check whether PR #359 (Phase 1) has merged to main by now. If it has, rebase this branch onto main first (git fetch origin && git rebase origin/main) so the PR below diffs cleanly against main instead of stacking on an open PR β resolve any conflicts, then re-run Step 1's full checklist. If #359 is still open, open this PR targeting the claude/buylist-phase-1 branch instead of main (gh pr create --base claude/buylist-phase-1 ...) and note in the body that it stacks on #359.
bash
git push -u origin claude/buylist-phase-2
gh pr create --title "Add the Smart Buylist quote engine and merchant order review flow" --body "$(cat <<'EOF'
Phase 2 of Smart Buylist (#318) per the approved spec
(docs/superpowers/specs/2026-07-14-smart-buylist-customer-intake-design.md),
building on the Phase 1 foundation (#359 β merge that first if still open).
- `buylistQuoteService`: plain-text list parser, plugin-driven catalog
matcher (`plugin.getProductModel()` β no per-game branching), basis-price
resolution reusing `priceLookupService`'s raw price lookups, and the new
buylist rate pipeline (rarity rate β bulk β stop-gap β condition multiplier)
- `POST/GET/PATCH /api/buylist/orders`, `POST /api/buylist/orders/:id/accept|decline`
- Buylist page restructured into Buy Orders / New Buy-In / Settings tabs,
plus a Review screen at `/buylist/orders/:id`
Game parity (Β§5.1): the matcher and pricer are plugin-driven by construction
β **mtg**, **pokemon**, and **riftbound** all resolve through the same
`plugin.getProductModel()`/`plugin.priceSources` calls, verified manually for
mtg and pokemon (riftbound's product-variant collection exists via the same
plugin interface but wasn't hand-verified with real catalog data this round).
Scope notes (documented, not oversights β see the plan's Global Constraints):
TCGPlayer-app-export and ManaBox CSV-specific parsing are out of scope
(plain-text only, matching the POC); manual re-matching of unmatched lines
(search-and-attach) is out of scope; `priceBasis: 'lower_of'` is
intentionally identical to `'lowest'` given the real price-data shape (no
separate lowest-listing field exists to distinguish them from `'market'`).
Later phases (public API/portal, kiosk auth, theme extension, payouts) land
as separate PRs per the spec.
π€ Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"Self-review (done at plan time) β
- Spec coverage (Phase 2 scope): parser β (Task 1), matcher/pricing/rate pipeline β (Task 2), order create/list/fetch/edit/accept/decline β (Tasks 3β6), merchant UI (Buy Orders, New Buy-In, Review β the POC screens) β (Tasks 7β10), quality bars + parity check β (Task 11). Out of scope by design and stated in the PR body: public API/portal, kiosk auth, theme extension, payouts (Phases 3β6); TCGPlayer/ManaBox-specific parsing; manual unmatched-line re-matching.
- Type/name consistency:
generateQuote's line output shape matchesBuylistOrder.buylistLineSchemafield-for-field (checked against Phase 1's model:rawLine, matchStatus, cardUuid, cardName, setCode, collectorNumber, finish, condition, quantity, basisPrice, ruleApplied, offerPrice, included).computeOrderTotals's return shape (cashTotal, creditTotal, creditBonus) matchesBuylistOrder.payout. Function names (matchCatalogLine,resolveBasisPrice,applyBuylistRate,computeOrderTotals,generateQuote) are used identically across Tasks 2, 4, and 5. - Known adaptation points (flagged inline with target behavior stated):
Button's destructive-variant name in Task 10 (verify against the real component, as Phase 1 did forAlert'sstatusprop). - Grounding: every literal/vocabulary/interface claim in the Global Constraints section was verified against real files or a direct database query during planning (cited inline), not assumed β per CLAUDE.md Β§5.4 and the repeated "Broken Join"/"Invented Vocabulary" failure classes this repo has hit before.
