Skip to content

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).
  • game is never defaulted anywhere (CLAUDE.md Β§5.5): missing/unsupported β†’ 400 or throw.
  • Match target is plugin.getProductModel() β€” returns ShopifyMTGProductVariant / ShopifyPokemonProductVariant / ShopifyRiftboundProductVariant uniformly (verified: server/plugins/{mtg,pokemon,riftbound}/index.js each implement this identically, e.g. server/plugins/mtg/index.js:428). These are global catalogs (no shop field) β€” 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 flat ShopifyMTGProduct model (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 β€” not metafields.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 on metafields.card_name as a fallback when set/number don't match or weren't parsed. Verified via direct DB query on both shopify_mtg_products_variants and shopify_pokemon_products_variants.
  • Rarity normalization: (doc.metafields.rarity || 'common').toLowerCase() β€” the exact pattern already used in server/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 finish string (e.g. "Nonfoil", "normal", "reverseHolofoil") straight through to getPricesForCard/getPricesForPokemonCard/getPricesForRiftboundCard as the finishTypes array entry. Verified by reading getPricesForCard's body (server/services/priceLookupService.js:767-855): the returned object (and its ._rawPrices sub-object) is keyed by the exact string you passed in, not a normalized key β€” read prices._rawPrices[finish] using that same literal string. Do not try to pre-normalize finish yourself or import FINISH_MAP into the new service β€” the price-lookup functions already apply FINISH_MAP internally for bucket selection; that's a separate step from the object's outer keying.
  • priceBasis semantics (decision confirmed with the user 2026-07-16, since the real price data doesn't support the original "Lowest Listing vs Market Price" distinction β€” mtgprices stores exactly one retail.<finish> number per source, verified by direct query, not separate market/low fields): 'market' = the first available price across plugin.priceSources in order (call getPricesForCard/etc. once with the default, unrestricted sourcePriority). 'lowest' and 'lower_of' both resolve to the minimum raw price across all sources in plugin.priceSources (call once per source, restricting sourcePriority to that one source, then Math.min the 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-level prices[finish] value (which may already have config'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 apply pricingConfig's sell-side stages; buylistConfig is a separate, already-built config shape with its own conditionMultipliers, 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.jsx is 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 in server/services/catalogPricingService.js (default real deps, tests inject mocks, afterEach(() => _resetDeps())).
  • Client editable-row pattern: controlled useState + curried setters bound to plain <Input>, exactly as BuylistPage.jsx's existing RateField/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 lint clean (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 in buylistQuoteService.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-1

If 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 -5

Known 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/collectorNumber are null when absent. condition is always one of nm|lp|mp|hp|damaged, defaulting to nm. 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.js

Expected: 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.js

Expected: 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); getPlugin from server/plugins (getPlugin(game).getProductModel(), .priceSources, throws on unsupported game); getGameBuylistConfig(store, game) from server/services/buylistConfigService.js; getPricesForCard/getPricesForPokemonCard/getPricesForRiftboundCard, DEFAULT_CONFIG from server/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.js

Expected: 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.js

Expected: 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 (validates POST /api/buylist/orders body: { customer: {email, name?}, rawText, payoutMethod? }), updateBuylistOrderLinesSchema (validates PATCH /api/buylist/orders/:id body: { 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.js

Expected: 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.js

Expected: 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 the PUT /buylist/config handler from Phase 1)

Interfaces:

  • Consumes: generateQuote, computeOrderTotals (Task 2); createBuylistOrderSchema (Task 3); getGameBuylistConfig (Phase 1); BuylistOrder model (Phase 1); req.store/req.query.game pattern already established by /buylist/config.

  • Produces: POST /api/buylist/orders?game=<id> β†’ creates and returns a BuylistOrder; 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 -5

Expected: 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 -5

Expected: 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 -5

Expected: 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's BuylistPage.jsx body, 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's BuylistPage.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); BuylistPage becomes 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.jsx

Expected: 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.jsx

Expected: 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.jsx

Expected: 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 -3

Expected: 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 by BuylistPage (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.jsx

Expected: 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.jsx

Expected: 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.jsx

Expected: 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.jsx

Expected: 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/:id route)

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.jsx

Expected: 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 &amp; 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 -3

Expected: 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-worker

In 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 matches BuylistOrder.buylistLineSchema field-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) matches BuylistOrder.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 for Alert's status prop).
  • 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.