Skip to content

Smart Buylist Selector Entry Implementation Plan โ€‹

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Let a merchant build a buy order by searching the catalog and picking exact cards (name, set, finish, condition, quantity) instead of relying solely on the free-text paste parser โ€” eliminating the whole class of matching-ambiguity bugs (ReDoS, wrong-card/wrong-printing matches) that the free-text path is inherently exposed to, while keeping paste available as a secondary bulk-import option.

Architecture: A new search-and-select UI in NewBuyInTab.jsx calls the existing GET /api/catalog/cards endpoint (no server changes needed there) and accumulates picks into a client-side running list. On submit, the client sends only { cardUuid, finish, condition, quantity } per line โ€” never a card name, set code, or rarity โ€” and the server re-derives every pricing-relevant fact itself via a fresh catalog lookup by cardUuid, then reuses the existing resolveBasisPrice/applyBuylistRate functions directly, skipping the free-text matcher entirely (no ambiguity is possible once a cardUuid is already known). POST /api/buylist/orders accepts either rawText (existing, unchanged) or lines (new) โ€” mutually exclusive โ€” and both paths converge on the same BuylistOrder.lines[] shape, so computeOrderTotals, persistence, and the Review screen need no changes at all.

Tech Stack: Node 24 / Express / Mongoose (CommonJS), Zod, Vitest (ESM tests), React 18 + retroui + Tailwind 4.

Spec: builds on docs/superpowers/specs/2026-07-14-smart-buylist-customer-intake-design.md and the Phase 2 plan (docs/superpowers/plans/2026-07-16-smart-buylist-phase-2.md). This is a direct response to user feedback on Phase 2's free-text-only entry: "why not a list of selectors where you craft the order?" Approved direction (2026-07-17): selector-based as the primary flow, paste retained as a secondary bulk-import option.

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.
  • Never trust client-supplied pricing-relevant fields. The client sends only cardUuid, finish, condition, quantity per selected line โ€” never cardName/setCode/collectorNumber/rarity. The server re-derives all of those from a fresh DB lookup by cardUuid (findCardByUuid, Task 1). This is stricter than the free-text path's matchCatalogLine (which has no choice but to trust a text-derived guess) โ€” selector-added lines have zero ambiguity by construction, and the server must not reintroduce any by trusting client-echoed display fields.
  • Match target is plugin.getProductModel() โ€” same global catalogs (ShopifyMTGProductVariant/ShopifyPokemonProductVariant/ShopifyRiftboundProductVariant) the free-text matcher already uses. findCardByUuid queries by identity (sourceCardUUID for MTG, sourceCardId for Pokemon/Riftbound โ€” query both via $or since only one field exists per game's schema and an absent field simply never matches, which is safe) โ€” never the legacy flat ShopifyMTGProduct model.
  • Rarity normalization: (doc.metafields.rarity || 'common').toLowerCase() โ€” exact pattern already used in matchCatalogLine (server/services/buylistQuoteService.js) and shopifyAPI.js. Reuse it verbatim in findCardByUuid, don't reinvent.
  • Finish validation: the client's requested finish (from the catalog search row's finishes[], or '' if the card has โ‰ค1 finish โ€” see client/src/hooks/useCardSyncAction.js's getDefaultFinish/hasMultipleFinishes) must be checked against the freshly-looked-up card's actual variants[] server-side; if it doesn't match any real variant (stale client cache, tampering, or the "no explicit choice" '' sentinel), fall back to variants[0].finish โ€” the same "base finish is first" assumption matchCatalogLine already relies on (verified in Phase 2: transformSetsToProducts.js sorts Nonfoil first at write time, and this holds for all three games).
  • Reuse, don't duplicate the price/rate pipeline. resolveBasisPrice({game, cardUuid, rarity, finish}, priceBasis, deps) and applyBuylistRate({basisPrice, rarity, condition, quantity}, buylistConfig) are called directly, identically to how priceAndRateLine (the free-text path's per-line helper) already calls them โ€” do not write a second pricing implementation.
  • Batch concurrency: reuse the existing GENERATE_QUOTE_BATCH_SIZE batching pattern from generateQuote for the new priceSelectedLines composition function, for the same reason it was added there (bounded concurrent DB/price-lookup work per request).
  • Either/or request body: no z.discriminatedUnion precedent exists in this repo (confirmed by search) โ€” use a plain .strict().refine() requiring exactly one of rawText/lines to be present. Keep both fields .optional() at the object level; the .refine() enforces mutual exclusivity with a single top-level error.
  • Condition vocabulary: reuse VALID_CONDITIONS exported from server/schemas/pricingConfig.js (already the established reuse pattern โ€” server/schemas/buylistOrder.js/buylistConfig.js do this) โ€” do not re-type ['nm','lp','mp','hp','damaged'] as a fresh literal.
  • Client search pattern: reuse the exact debounced-search idiom already in client/src/pages/catalog/CatalogSinglesPage.jsx's CardSetPicker (300ms setTimeout/clearTimeout debounce) โ€” don't invent a different debounce mechanism.
  • Client condition/finish controls: reuse getDefaultFinish/hasMultipleFinishes from client/src/hooks/useCardSyncAction.js (already exported, already codebase convention) for finish defaulting. CardSyncControls.jsx's CONDITIONS label array ([{value:'nm',label:'NM'}, ...]) is a local, unexported const โ€” redeclare an identical array rather than importing it (confirmed: no export exists to reuse), but the five values must exactly match ['nm','lp','mp','hp','damaged'].
  • retroui has no Table component โ€” the running-list is plain HTML + Tailwind, matching every other buylist table built in Phase 2 (client/src/components/ProductList.jsx is the original precedent).
  • Server DI pattern: _setDeps/_resetDeps/getDeps() triple, already established in buylistQuoteService.js โ€” extend the same getDeps() default object with the new function, don't create a second DI mechanism.
  • 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): findCardByUuid/priceIdentifiedLine/priceSelectedLines are plugin-driven by construction (same plugin.getProductModel()/plugin.priceSources calls the free-text path already uses) โ€” no per-game branching. State this in the PR description, naming all three games.

Task 0: Preflight โ€‹

Files: none (environment only)

  • [ ] Step 1: Branch from the Phase 2 branch (stacked, same reason as Phase 2 stacking on Phase 1)

As of 2026-07-17, Phase 2's PR (#362) is open and stacked on the still-open Phase 1 PR (#359). This plan's code depends entirely on Phase 2's buylistQuoteService.js/BuylistOrder/NewBuyInTab.jsx, so branch from Phase 2, not main:

bash
git fetch origin
git checkout -b claude/buylist-selector-entry origin/claude/buylist-phase-2

If PR #362 has since merged to main by the time you run this, prefer origin/main instead (check git log origin/main --oneline | grep -i "selector\|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: Server โ€” findCardByUuid, priceIdentifiedLine, priceSelectedLines โ€‹

Files:

  • Modify: server/services/buylistQuoteService.js (append new functions, extend getDeps() and module.exports)
  • Modify: server/services/buylistQuoteService.test.js (append tests)

Interfaces:

  • Consumes: getPlugin(game).getProductModel(), getGameBuylistConfig(store, game), resolveBasisPrice, applyBuylistRate โ€” all already present in this file.

  • Produces (consumed by Task 3's route):

    • findCardByUuid(cardUuid, game, deps = getDeps()) โ†’ Promise<{cardUuid, cardName, setCode, collectorNumber, rarity, variants} | null>
    • priceIdentifiedLine(selected, game, buylistConfig, deps) โ†’ Promise<BuylistOrder line shape> where selected = {cardUuid, finish, condition, quantity}
    • priceSelectedLines({store, game, lines}, deps = getDeps()) โ†’ Promise<{lines: [...]}> โ€” mirrors generateQuote's signature/return shape exactly, for the selector path.
  • [ ] Step 1: Write the failing tests

Append to server/services/buylistQuoteService.test.js:

javascript
describe('findCardByUuid', () => {
    afterEach(() => _resetDeps());

    it('looks up by sourceCardUUID or sourceCardId and returns normalized identity', 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' }]
        }) });
        _setDeps({ getPlugin: () => ({ getProductModel: () => ({ findOne }) }) });

        const card = await findCardByUuid('uuid-1', 'mtg');

        expect(findOne).toHaveBeenCalledWith({ $or: [{ sourceCardUUID: 'uuid-1' }, { sourceCardId: 'uuid-1' }] });
        expect(card).toEqual({
            cardUuid: 'uuid-1',
            cardName: 'Ragavan, Nimble Pilferer',
            setCode: 'MH2',
            collectorNumber: '138',
            rarity: 'mythic',
            variants: [{ finish: 'Nonfoil' }, { finish: 'Foil' }]
        });
    });

    it('returns null when no card matches', async () => {
        const findOne = vi.fn().mockReturnValue({ lean: () => Promise.resolve(null) });
        _setDeps({ getPlugin: () => ({ getProductModel: () => ({ findOne }) }) });

        expect(await findCardByUuid('missing-uuid', 'mtg')).toBeNull();
    });
});

describe('priceIdentifiedLine', () => {
    afterEach(() => _resetDeps());

    const buylistConfig = { defaultRate: 0.40, rarityRates: {}, bulk: { enabled: false }, stopGap: { enabled: false }, conditionMultipliers: { nm: 1.0 }, priceBasis: 'market' };

    it('prices a selected card by its requested finish, skipping any text matching', async () => {
        const card = { cardUuid: 'uuid-1', cardName: 'Ragavan, Nimble Pilferer', setCode: 'MH2', collectorNumber: '138', rarity: 'mythic', variants: [{ finish: 'Nonfoil' }, { finish: 'Foil' }] };
        const findCardByUuid = vi.fn().mockResolvedValue(card);
        const resolveBasisPrice = vi.fn().mockResolvedValue(48.50);
        const applyBuylistRate = vi.fn().mockReturnValue({ offerPrice: 19.40, ruleApplied: 'default', lineTotal: 19.40 });
        const deps = { findCardByUuid, resolveBasisPrice, applyBuylistRate };

        const line = await priceIdentifiedLine({ cardUuid: 'uuid-1', finish: 'Foil', condition: 'nm', quantity: 1 }, 'mtg', buylistConfig, deps);

        expect(resolveBasisPrice).toHaveBeenCalledWith({ game: 'mtg', cardUuid: 'uuid-1', rarity: 'mythic', finish: 'Foil' }, 'market', deps);
        expect(line).toEqual({
            rawLine: '1 Ragavan, Nimble Pilferer [MH2] 138',
            matchStatus: 'matched',
            cardUuid: 'uuid-1',
            cardName: 'Ragavan, Nimble Pilferer',
            setCode: 'MH2',
            collectorNumber: '138',
            finish: 'Foil',
            condition: 'nm',
            quantity: 1,
            basisPrice: 48.50,
            ruleApplied: 'default',
            offerPrice: 19.40,
            included: true
        });
    });

    it('falls back to the first variant finish when the requested finish does not exist on this card', async () => {
        const card = { cardUuid: 'uuid-1', cardName: 'Ragavan, Nimble Pilferer', setCode: 'MH2', collectorNumber: '138', rarity: 'mythic', variants: [{ finish: 'Nonfoil' }, { finish: 'Foil' }] };
        const findCardByUuid = vi.fn().mockResolvedValue(card);
        const resolveBasisPrice = vi.fn().mockResolvedValue(48.50);
        const applyBuylistRate = vi.fn().mockReturnValue({ offerPrice: 19.40, ruleApplied: 'default', lineTotal: 19.40 });
        const deps = { findCardByUuid, resolveBasisPrice, applyBuylistRate };

        // '' is the "no explicit choice" sentinel the client sends for single/zero-finish cards
        const line = await priceIdentifiedLine({ cardUuid: 'uuid-1', finish: '', condition: 'nm', quantity: 1 }, 'mtg', buylistConfig, deps);

        expect(resolveBasisPrice).toHaveBeenCalledWith({ game: 'mtg', cardUuid: 'uuid-1', rarity: 'mythic', finish: 'Nonfoil' }, 'market', deps);
        expect(line.finish).toBe('Nonfoil');
    });

    it('returns an unmatched line when the card no longer exists', async () => {
        const findCardByUuid = vi.fn().mockResolvedValue(null);
        const deps = { findCardByUuid };

        const line = await priceIdentifiedLine({ cardUuid: 'stale-uuid', finish: 'Foil', condition: 'lp', quantity: 3 }, 'mtg', buylistConfig, deps);

        expect(line).toEqual({
            rawLine: '3 [unavailable card stale-uuid]',
            matchStatus: 'unmatched',
            quantity: 3,
            condition: 'lp',
            included: false
        });
    });
});

describe('priceSelectedLines', () => {
    afterEach(() => _resetDeps());

    it('prices every selected line and composes the same shape as generateQuote', async () => {
        const store = { buylistConfig: {} };
        _setDeps({
            getGameBuylistConfig: () => ({ defaultRate: 0.40, priceBasis: 'market' }),
            priceIdentifiedLine: async (selected) => ({
                rawLine: `${selected.quantity} Test Card [TST] 1`,
                matchStatus: 'matched',
                cardUuid: selected.cardUuid,
                cardName: 'Test Card',
                setCode: 'TST',
                collectorNumber: '1',
                finish: 'Nonfoil',
                condition: selected.condition,
                quantity: selected.quantity,
                basisPrice: 10,
                ruleApplied: 'default',
                offerPrice: 4,
                included: true
            })
        });

        const result = await priceSelectedLines({
            store, game: 'mtg',
            lines: [{ cardUuid: 'uuid-1', finish: 'Nonfoil', condition: 'nm', quantity: 2 }]
        });

        expect(result.lines).toHaveLength(1);
        expect(result.lines[0].cardUuid).toBe('uuid-1');
        expect(result.lines[0].offerPrice).toBe(4);
    });
});

Add findCardByUuid, priceIdentifiedLine, priceSelectedLines to this test file's existing import line from ./buylistQuoteService.js.

  • [ ] 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 three functions

Read the current file first (server/services/buylistQuoteService.js) to find the exact location of getDeps() and the final module.exports block โ€” insert the new functions directly above module.exports, and extend both getDeps()'s returned object and the exports list.

Add these functions (after priceAndRateLine, before generateQuote, or directly after generateQuote โ€” either is fine, keep related functions grouped):

javascript
/**
 * Reverse lookup: given a cardUuid a merchant already picked via search,
 * fetch its current identity fresh from the catalog. Never trust a
 * client-echoed cardName/setCode/rarity for a selector-added line โ€” only
 * cardUuid is treated as merchant intent; everything else is re-derived
 * here so a stale client cache or tampering can't affect pricing.
 */
async function findCardByUuid(cardUuid, game, deps = getDeps()) {
    const plugin = deps.getPlugin(game);
    const Model = plugin.getProductModel();
    const doc = await Model.findOne({
        $or: [{ sourceCardUUID: cardUuid }, { sourceCardId: cardUuid }]
    }).lean();
    if (!doc) return null;

    return {
        cardUuid,
        cardName: doc.metafields.card_name,
        setCode: doc.metafields.set_code,
        collectorNumber: doc.metafields.collector_number,
        rarity: (doc.metafields.rarity || 'common').toLowerCase(),
        variants: doc.variants
    };
}

/**
 * Prices one selector-added line. Unlike priceAndRateLine (the free-text
 * path), there is no matching step โ€” cardUuid already identifies the exact
 * card, so this can never produce a wrong-card match. The only thing
 * validated here is that the requested finish is real for this card.
 */
async function priceIdentifiedLine(selected, game, buylistConfig, deps) {
    const card = await deps.findCardByUuid(selected.cardUuid, game, deps);
    if (!card) {
        return {
            rawLine: `${selected.quantity} [unavailable card ${selected.cardUuid}]`,
            matchStatus: 'unmatched',
            quantity: selected.quantity,
            condition: selected.condition,
            included: false
        };
    }

    // '' is the client's "no explicit choice" sentinel for single/zero-finish
    // cards; an unrecognized finish (stale cache) also falls back here.
    const finish = card.variants.some((v) => v.finish === selected.finish)
        ? selected.finish
        : card.variants[0].finish;

    const basisPrice = await deps.resolveBasisPrice(
        { game, cardUuid: card.cardUuid, rarity: card.rarity, finish },
        buylistConfig.priceBasis,
        deps
    );
    const { offerPrice, ruleApplied } = deps.applyBuylistRate(
        { basisPrice, rarity: card.rarity, condition: selected.condition, quantity: selected.quantity },
        buylistConfig
    );

    return {
        rawLine: `${selected.quantity} ${card.cardName} [${card.setCode}] ${card.collectorNumber}`,
        matchStatus: 'matched',
        cardUuid: card.cardUuid,
        cardName: card.cardName,
        setCode: card.setCode,
        collectorNumber: card.collectorNumber,
        finish,
        condition: selected.condition,
        quantity: selected.quantity,
        basisPrice,
        ruleApplied,
        offerPrice,
        included: true
    };
}

/**
 * Top-level composition for the selector path โ€” the sibling of
 * generateQuote for pre-identified lines. Same batching for the same
 * bounded-concurrency reason (see GENERATE_QUOTE_BATCH_SIZE above
 * generateQuote).
 */
async function priceSelectedLines({ store, game, lines }, deps = getDeps()) {
    const buylistConfig = deps.getGameBuylistConfig(store, game);

    const results = [];
    for (let i = 0; i < lines.length; i += GENERATE_QUOTE_BATCH_SIZE) {
        const batch = lines.slice(i, i + GENERATE_QUOTE_BATCH_SIZE);
        const batchResults = await Promise.all(
            batch.map((selected) => deps.priceIdentifiedLine(selected, game, buylistConfig, deps))
        );
        results.push(...batchResults);
    }

    return { lines: results };
}

In getDeps()'s returned default-deps object, add:

javascript
        findCardByUuid,
        priceIdentifiedLine,

(alongside the existing resolveBasisPrice, applyBuylistRate, etc. โ€” read the current object literal first to match its exact style.)

In the final module.exports block, add findCardByUuid, priceIdentifiedLine, priceSelectedLines, to the list.

  • [ ] Step 4: Run the tests to verify they pass
bash
npx vitest run server/services/buylistQuoteService.test.js

Expected: PASS (all existing tests + 6 new)

  • [ ] Step 5: Commit
bash
git add server/services/buylistQuoteService.js server/services/buylistQuoteService.test.js
git commit -m "Add a quote-engine path for pricing merchant-selected cards directly"

Task 2: Server โ€” either/or request schema โ€‹

Files:

  • Modify: server/schemas/buylistOrder.js
  • Modify: server/schemas/schemas.test.js (append tests)

Interfaces:

  • Produces: createBuylistOrderSchema now accepts { customer, rawText?, lines?, payoutMethod? } with exactly one of rawText/lines required. lines[] items are { cardUuid, finish, condition, quantity }. Consumed by Task 3's route.

  • [ ] Step 1: Write the failing tests

Append to the existing describe('createBuylistOrderSchema', ...) block in server/schemas/schemas.test.js (find it โ€” it already has 6 tests from Phase 2):

javascript
    it('accepts a valid selector-based body (lines instead of rawText)', () => {
        const result = createBuylistOrderSchema.safeParse({
            customer: { email: 'jamie@example.com' },
            lines: [{ cardUuid: 'uuid-1', finish: 'Nonfoil', condition: 'nm', quantity: 2 }]
        });
        expect(result.success).toBe(true);
    });

    it('accepts an empty-string finish (the "no explicit choice" sentinel)', () => {
        const result = createBuylistOrderSchema.safeParse({
            customer: { email: 'jamie@example.com' },
            lines: [{ cardUuid: 'uuid-1', finish: '', condition: 'nm', quantity: 1 }]
        });
        expect(result.success).toBe(true);
    });

    it('rejects a body with neither rawText nor lines', () => {
        const result = createBuylistOrderSchema.safeParse({
            customer: { email: 'jamie@example.com' }
        });
        expect(result.success).toBe(false);
    });

    it('rejects a body with both rawText and lines', () => {
        const result = createBuylistOrderSchema.safeParse({
            customer: { email: 'jamie@example.com' },
            rawText: '1 Lightning Bolt [CLB] 187',
            lines: [{ cardUuid: 'uuid-1', finish: 'Nonfoil', condition: 'nm', quantity: 1 }]
        });
        expect(result.success).toBe(false);
    });

    it('rejects an empty lines array', () => {
        const result = createBuylistOrderSchema.safeParse({
            customer: { email: 'jamie@example.com' },
            lines: []
        });
        expect(result.success).toBe(false);
    });

    it('rejects a lines entry missing cardUuid', () => {
        const result = createBuylistOrderSchema.safeParse({
            customer: { email: 'jamie@example.com' },
            lines: [{ finish: 'Nonfoil', condition: 'nm', quantity: 1 }]
        });
        expect(result.success).toBe(false);
    });

    it('rejects a lines entry with an invalid condition', () => {
        const result = createBuylistOrderSchema.safeParse({
            customer: { email: 'jamie@example.com' },
            lines: [{ cardUuid: 'uuid-1', finish: 'Nonfoil', condition: 'mint', quantity: 1 }]
        });
        expect(result.success).toBe(false);
    });

    it('rejects a non-positive quantity in a lines entry', () => {
        const result = createBuylistOrderSchema.safeParse({
            customer: { email: 'jamie@example.com' },
            lines: [{ cardUuid: 'uuid-1', finish: 'Nonfoil', condition: 'nm', quantity: 0 }]
        });
        expect(result.success).toBe(false);
    });

    it('rejects more than 500 lines', () => {
        const lines = Array.from({ length: 501 }, (_, i) => ({ cardUuid: `uuid-${i}`, finish: '', condition: 'nm', quantity: 1 }));
        const result = createBuylistOrderSchema.safeParse({
            customer: { email: 'jamie@example.com' },
            lines
        });
        expect(result.success).toBe(false);
    });
  • [ ] Step 2: Run the tests to verify they fail
bash
npx vitest run server/schemas/schemas.test.js

Expected: FAIL โ€” rawText is currently required (.min(1) with no .optional()), so the new either/or shape doesn't exist yet.

  • [ ] Step 3: Update the schema

Replace server/schemas/buylistOrder.js's createBuylistOrderSchema (leave updateBuylistOrderLinesSchema untouched):

javascript
'use strict';

const { z } = require('zod');
const { VALID_CONDITIONS } = require('./pricingConfig');

const selectedCardLineSchema = z.object({
    cardUuid: z.string().min(1, 'cardUuid is required'),
    // '' means "no explicit finish choice" (single/zero-finish card on the
    // client) โ€” the server re-derives the real finish; see priceIdentifiedLine.
    finish: z.string(),
    condition: z.enum(VALID_CONDITIONS),
    quantity: z.number().int().min(1).max(9999)
}).strict();

const createBuylistOrderSchema = z.object({
    customer: z.object({
        email: z.string().email('customer.email must be a valid email'),
        name: z.string().optional()
    }),
    rawText: z.string().trim().min(1, 'rawText must not be blank').max(65536, 'rawText is too long').optional(),
    lines: z.array(selectedCardLineSchema).min(1, 'lines must not be empty').max(500, 'too many lines').optional(),
    payoutMethod: z.enum(['store_credit', 'cash']).optional()
}).strict().refine(
    (data) => (data.rawText != null) !== (data.lines != null),
    { message: 'Provide either rawText or lines, but not both' }
);

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 };
  • [ ] Step 4: Run the tests to verify they pass
bash
npx vitest run server/schemas/schemas.test.js

Expected: PASS (existing count + 9 new)

  • [ ] Step 5: Commit
bash
git add server/schemas/buylistOrder.js server/schemas/schemas.test.js
git commit -m "Accept a list of merchant-selected cards as an alternative to pasted text"

Task 3: Server โ€” branch the create-order route โ€‹

Files:

  • Modify: server/routes/api.js (the POST /buylist/orders handler)

Interfaces:

  • Consumes: priceSelectedLines (Task 1), the updated createBuylistOrderSchema (Task 2).

  • Produces: POST /api/buylist/orders?game=<id> now accepts either body shape; response shape ({ order }) is unchanged either way.

  • [ ] Step 1: Update the handler

Find the existing POST /buylist/orders handler (search router.post('/buylist/orders'). Replace the single generateQuote call with a branch:

javascript
        const { generateQuote, priceSelectedLines, computeOrderTotals } = require('../services/buylistQuoteService');
        const { getGameBuylistConfig, assertBuylistEnabled } = require('../services/buylistConfigService');
        const buylistConfig = getGameBuylistConfig(req.store, game);
        const disabledError = assertBuylistEnabled(buylistConfig);
        if (disabledError) {
            return res.status(400).json({ error: disabledError });
        }

        const quote = parsed.data.rawText
            ? await generateQuote({ store: req.store, game, rawText: parsed.data.rawText })
            : await priceSelectedLines({ store: req.store, game, lines: parsed.data.lines });
        const totals = computeOrderTotals(quote.lines, buylistConfig);

Leave every other line of the handler (validation, BuylistOrder construction, .save(), logging, error handling) exactly as it is โ€” only the quote-generation line changes from a single call to this branch.

  • [ ] Step 2: Run the full server suite
bash
npm test 2>&1 | tail -5

Expected: all passing (route has no dedicated test file โ€” matches the repo's established pattern for api.js handlers, covered indirectly via Tasks 1โ€“2's service/schema tests).

  • [ ] Step 3: Manual end-to-end check (requires docker compose up -d, local Mongo/Redis)
bash
node -e "
const mongoose = require('mongoose');
(async () => {
  await mongoose.connect('mongodb://localhost:27017/shopify_test');
  const { priceSelectedLines, computeOrderTotals } = require('./server/services/buylistQuoteService');
  const Store = require('./server/models/Store');
  const { applyGameBuylistConfigUpdate, getGameBuylistConfig } = require('./server/services/buylistConfigService');
  const ShopifyMTGProductVariant = require('./server/models/ShopifyMTGProductVariant');

  const store = new Store({ shop: 'e2e-selector-check.myshopify.com', accessToken: 't', scope: 'read_products' });
  applyGameBuylistConfigUpdate(store, 'mtg', { enabled: true });

  const anyCard = await ShopifyMTGProductVariant.findOne({}).lean();
  const uuid = anyCard.sourceCardUUID;
  const quote = await priceSelectedLines({ store, game: 'mtg', lines: [{ cardUuid: uuid, finish: anyCard.variants[0].finish, condition: 'nm', quantity: 1 }] });
  console.log(JSON.stringify(quote, null, 2));
  console.log(computeOrderTotals(quote.lines, getGameBuylistConfig(store, 'mtg')));
  await mongoose.disconnect();
})().catch(e => { console.error('FAILED:', e); process.exit(1); });
"

Expected: a single matchStatus: 'matched' line with a real cardName/offerPrice, since the uuid was pulled straight from a real catalog document.

  • [ ] Step 4: Commit
bash
git add server/routes/api.js
git commit -m "Let the create-order endpoint accept merchant-selected cards or pasted text"

Task 4: Client โ€” CardSearchPicker component โ€‹

Files:

  • Create: client/src/pages/buylist/CardSearchPicker.jsx
  • Create: client/src/pages/buylist/CardSearchPicker.test.jsx

Interfaces:

  • Consumes: GET /catalog/cards?game=&search= via api from ../../utils/api. Response shape per Phase 2 research: { cards: [{uuid, name, number, setCode, setName, rarity, finishes: string[], imageUri, latestPrice, yourPrice}], pagination }.

  • Produces: default-exported CardSearchPicker({ game, onSelect }) โ€” renders a debounced search input + a results dropdown; calling onSelect(card) with the clicked card object and clearing the search box is the component's only side effect. Task 5 owns everything after that (the running list).

  • [ ] Step 1: Write the failing test

client/src/pages/buylist/CardSearchPicker.test.jsx:

jsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import CardSearchPicker from './CardSearchPicker';

vi.mock('../../utils/api', () => ({ default: { get: vi.fn() } }));
import api from '../../utils/api';

beforeEach(() => {
  vi.clearAllMocks();
  vi.useFakeTimers();
});

afterEach(() => {
  vi.useRealTimers();
});

describe('CardSearchPicker', () => {
  it('debounces the search and renders results', async () => {
    api.get.mockResolvedValue({ data: { cards: [
      { uuid: 'uuid-1', name: 'Ragavan, Nimble Pilferer', number: '138', setCode: 'MH2', setName: 'Modern Horizons 2', rarity: 'mythic', finishes: ['Nonfoil', 'Foil'] }
    ] } });
    const onSelect = vi.fn();
    render(<CardSearchPicker game="mtg" onSelect={onSelect} />);

    fireEvent.change(screen.getByLabelText(/search cards/i), { target: { value: 'Ragavan' } });
    expect(api.get).not.toHaveBeenCalled();

    vi.advanceTimersByTime(300);
    await waitFor(() => expect(api.get).toHaveBeenCalledWith('/catalog/cards', { params: { search: 'Ragavan', game: 'mtg', limit: 8 } }));
    expect(await screen.findByText('Ragavan, Nimble Pilferer')).toBeInTheDocument();
  });

  it('calls onSelect with the clicked card and clears the search box', async () => {
    api.get.mockResolvedValue({ data: { cards: [
      { uuid: 'uuid-1', name: 'Ragavan, Nimble Pilferer', number: '138', setCode: 'MH2', setName: 'Modern Horizons 2', rarity: 'mythic', finishes: ['Nonfoil', 'Foil'] }
    ] } });
    const onSelect = vi.fn();
    render(<CardSearchPicker game="mtg" onSelect={onSelect} />);

    const input = screen.getByLabelText(/search cards/i);
    fireEvent.change(input, { target: { value: 'Ragavan' } });
    vi.advanceTimersByTime(300);
    fireEvent.click(await screen.findByText('Ragavan, Nimble Pilferer'));

    expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ uuid: 'uuid-1', name: 'Ragavan, Nimble Pilferer' }));
    expect(input).toHaveValue('');
  });

  it('shows nothing and makes no request for a blank query', () => {
    const onSelect = vi.fn();
    render(<CardSearchPicker game="mtg" onSelect={onSelect} />);
    vi.advanceTimersByTime(300);
    expect(api.get).not.toHaveBeenCalled();
  });
});
  • [ ] Step 2: Run to verify it fails
bash
npx vitest run --config vitest.client.config.js client/src/pages/buylist/CardSearchPicker.test.jsx

Expected: FAIL โ€” Cannot find module './CardSearchPicker'

  • [ ] Step 3: Implement the component

client/src/pages/buylist/CardSearchPicker.jsx:

jsx
import { useEffect, useRef, useState } from 'react';
import api from '../../utils/api';
import { Input, Text } from '../../components/retroui';

const SEARCH_DEBOUNCE_MS = 300;

export default function CardSearchPicker({ game, onSelect }) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [searching, setSearching] = useState(false);
  const debounceRef = useRef(null);

  useEffect(() => {
    if (!query.trim()) {
      setResults([]);
      return undefined;
    }
    if (debounceRef.current) clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(async () => {
      setSearching(true);
      try {
        const { data } = await api.get('/catalog/cards', { params: { search: query.trim(), game, limit: 8 } });
        setResults(data.cards || []);
      } catch {
        setResults([]);
      } finally {
        setSearching(false);
      }
    }, SEARCH_DEBOUNCE_MS);
    return () => clearTimeout(debounceRef.current);
  }, [query, game]);

  const pick = (card) => {
    onSelect(card);
    setQuery('');
    setResults([]);
  };

  return (
    <div className="relative">
      <label htmlFor="card-search" className="font-semibold text-sm block mb-1">Search cards to add</label>
      <Input
        id="card-search"
        aria-label="Search cards"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Start typing a card nameโ€ฆ"
      />
      {searching && <Text className="text-xs text-muted-foreground mt-1">Searchingโ€ฆ</Text>}
      {results.length > 0 && (
        <ul className="absolute z-10 mt-1 w-full max-w-md bg-card border-2 border-black rounded shadow-md">
          {results.map((card) => (
            <li key={card.uuid}>
              <button
                type="button"
                className="w-full text-left px-3 py-2 hover:bg-muted text-sm"
                onClick={() => pick(card)}
              >
                <span className="font-semibold">{card.name}</span>
                <span className="text-muted-foreground"> โ€” {card.setName} #{card.number} ยท {card.rarity}</span>
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
  • [ ] Step 4: Run to verify it passes
bash
npx vitest run --config vitest.client.config.js client/src/pages/buylist/CardSearchPicker.test.jsx

Expected: PASS (3 tests)

  • [ ] Step 5: Commit
bash
git add client/src/pages/buylist/CardSearchPicker.jsx client/src/pages/buylist/CardSearchPicker.test.jsx
git commit -m "Add a debounced card search picker for building a buy order by hand"

Task 5: Client โ€” running list + mode toggle in NewBuyInTab โ€‹

Files:

  • Modify: client/src/pages/buylist/NewBuyInTab.jsx
  • Modify: client/src/pages/buylist/NewBuyInTab.test.jsx (append tests)

Interfaces:

  • Consumes: CardSearchPicker (Task 4); getDefaultFinish, hasMultipleFinishes from ../../hooks/useCardSyncAction; POST /buylist/orders?game= now accepting { lines } (Task 2/3).
  • Produces: NewBuyInTab gains a "Search & Select" / "Paste List" mode toggle. In select mode, picking a card via CardSearchPicker adds a row to a running list with condition/finish selectors, a quantity input, and a remove button; submitting posts { customer, lines } instead of { customer, rawText }.

Read the current NewBuyInTab.jsx in full first (via cat if the Read tool truncates it) so you extend it rather than guess its existing state/JSX shape.

  • [ ] Step 1: Write the failing tests

Append to client/src/pages/buylist/NewBuyInTab.test.jsx (the existing file already has 2 tests covering the paste-mode submit and its error path โ€” leave those untouched):

jsx
describe('NewBuyInTab โ€” search & select mode', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    api.get.mockImplementation((url) => {
      if (url === '/games') return Promise.resolve({ data: { games: [{ id: 'mtg', name: 'Magic: The Gathering' }] } });
      if (url === '/catalog/cards') return Promise.resolve({ data: { cards: [
        { uuid: 'uuid-1', name: 'Ragavan, Nimble Pilferer', number: '138', setCode: 'MH2', setName: 'Modern Horizons 2', rarity: 'mythic', finishes: ['Nonfoil', 'Foil'] }
      ] } });
      return Promise.resolve({ data: {} });
    });
    api.post.mockResolvedValue({ data: { order: { _id: 'order-2' } } });
  });

  it('adds a searched card to the running list and submits it as lines', async () => {
    render(<MemoryRouter><NewBuyInTab /></MemoryRouter>);
    fireEvent.change(await screen.findByLabelText(/customer email/i), { target: { value: 'jamie@example.com' } });

    fireEvent.click(screen.getByRole('button', { name: /search & select/i }));
    fireEvent.change(screen.getByLabelText(/search cards/i), { target: { value: 'Ragavan' } });
    fireEvent.click(await screen.findByText('Ragavan, Nimble Pilferer'));

    expect(await screen.findByText('Ragavan, Nimble Pilferer', { selector: 'td *, td' })).toBeInTheDocument();

    fireEvent.click(screen.getByRole('button', { name: /generate offer/i }));

    await waitFor(() => expect(api.post).toHaveBeenCalledWith(
      '/buylist/orders?game=mtg',
      {
        customer: { email: 'jamie@example.com' },
        lines: [{ cardUuid: 'uuid-1', finish: 'Nonfoil', condition: 'nm', quantity: 1 }]
      }
    ));
    expect(mockNavigate).toHaveBeenCalledWith('/buylist/orders/order-2');
  });

  it('removes a row from the running list', async () => {
    render(<MemoryRouter><NewBuyInTab /></MemoryRouter>);
    fireEvent.click(screen.getByRole('button', { name: /search & select/i }));
    fireEvent.change(screen.getByLabelText(/search cards/i), { target: { value: 'Ragavan' } });
    fireEvent.click(await screen.findByText('Ragavan, Nimble Pilferer'));
    await screen.findByText('Ragavan, Nimble Pilferer', { selector: 'td *, td' });

    fireEvent.click(screen.getByRole('button', { name: /remove ragavan/i }));

    expect(screen.queryByText('Ragavan, Nimble Pilferer', { selector: 'td *, td' })).not.toBeInTheDocument();
  });
});

Adaptation note (not a placeholder โ€” this is the target behavior, adapt only the exact query/selector syntax to whatever actually renders): the { selector: 'td *, td' } option is a hint that the card name should render inside a table cell in the running list; if your implementation renders it differently, use whatever screen.getByText/findByText call correctly targets the running-list row's name cell โ€” the point is asserting the row appears/disappears, not the exact selector string.

  • [ ] Step 2: Run to verify the new tests fail
bash
npx vitest run --config vitest.client.config.js client/src/pages/buylist/NewBuyInTab.test.jsx

Expected: FAIL โ€” no mode toggle, no running list exist yet.

  • [ ] Step 3: Implement the running list and mode toggle

Modify client/src/pages/buylist/NewBuyInTab.jsx. Add these imports alongside the existing ones:

jsx
import CardSearchPicker from './CardSearchPicker';
import { getDefaultFinish, hasMultipleFinishes } from '../../hooks/useCardSyncAction';

Add a local conditions array near the top of the file (matches CardSyncControls.jsx's unexported CONDITIONS โ€” redeclared here since it isn't exported):

jsx
const CONDITIONS = [
  { value: 'nm', label: 'NM' },
  { value: 'lp', label: 'LP' },
  { value: 'mp', label: 'MP' },
  { value: 'hp', label: 'HP' },
  { value: 'damaged', label: 'DMG' }
];

Inside the NewBuyInTab function component, add state for the mode toggle and the running list:

jsx
  const [mode, setMode] = useState('select'); // 'select' | 'paste'
  const [selectedCards, setSelectedCards] = useState([]); // running list

  const addCard = (card) => {
    setSelectedCards((prev) => {
      if (prev.some((c) => c.uuid === card.uuid)) return prev; // no duplicate rows
      return [...prev, {
        uuid: card.uuid,
        name: card.name,
        setCode: card.setCode,
        number: card.number,
        finishes: card.finishes || [],
        finish: getDefaultFinish(card),
        condition: 'nm',
        quantity: 1
      }];
    });
  };

  const removeCard = (uuid) => {
    setSelectedCards((prev) => prev.filter((c) => c.uuid !== uuid));
  };

  const updateCard = (uuid, field, value) => {
    setSelectedCards((prev) => prev.map((c) => (c.uuid === uuid ? { ...c, [field]: value } : c)));
  };

Update the submit function's body construction to branch on mode (find the existing const { data } = await api.post(...) call and adjust the body it sends):

jsx
      const customer = name ? { email, name } : { email };
      const body = mode === 'paste'
        ? { customer, rawText }
        : {
            customer,
            lines: selectedCards.map((c) => ({
              cardUuid: c.uuid,
              finish: c.finish,
              condition: c.condition,
              quantity: Number(c.quantity)
            }))
          };
      const { data } = await api.post(`/buylist/orders?game=${game}`, body);

Also update the submit button's disabled condition to account for both modes (find the existing <Button onClick={submit} disabled={...}> and adjust):

jsx
      <Button onClick={submit} disabled={submitting || !email || (mode === 'paste' ? !rawText : selectedCards.length === 0)}>
        {submitting ? 'Generatingโ€ฆ' : 'Generate Offer'}
      </Button>

Add the mode toggle and the two mode bodies in the JSX, replacing wherever the textarea currently renders unconditionally:

jsx
      <div className="flex gap-2">
        <Button variant={mode === 'select' ? 'default' : 'outline'} size="sm" onClick={() => setMode('select')}>
          Search &amp; Select
        </Button>
        <Button variant={mode === 'paste' ? 'default' : 'outline'} size="sm" onClick={() => setMode('paste')}>
          Paste List
        </Button>
      </div>

      {mode === 'select' ? (
        <div className="space-y-4">
          <CardSearchPicker game={game} onSelect={addCard} />

          {selectedCards.length > 0 && (
            <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">Finish</th>
                    <th className="px-4 py-2 text-left">Condition</th>
                    <th className="px-4 py-2 text-left">Qty</th>
                    <th className="px-4 py-2 text-left"></th>
                  </tr>
                </thead>
                <tbody>
                  {selectedCards.map((c) => (
                    <tr key={c.uuid} className="border-b border-black/20">
                      <td className="px-4 py-2">{c.name} <span className="text-muted-foreground">[{c.setCode}] {c.number}</span></td>
                      <td className="px-4 py-2">
                        {hasMultipleFinishes({ finishes: c.finishes }) ? (
                          <select
                            aria-label={`Finish for ${c.name}`}
                            className="border-2 border-black rounded px-2 py-1 text-xs bg-card"
                            value={c.finish}
                            onChange={(e) => updateCard(c.uuid, 'finish', e.target.value)}
                          >
                            {c.finishes.map((f) => <option key={f} value={f}>{f}</option>)}
                          </select>
                        ) : (
                          <span className="text-muted-foreground text-xs">{c.finishes[0] || 'โ€”'}</span>
                        )}
                      </td>
                      <td className="px-4 py-2">
                        <select
                          aria-label={`Condition for ${c.name}`}
                          className="border-2 border-black rounded px-2 py-1 text-xs bg-card"
                          value={c.condition}
                          onChange={(e) => updateCard(c.uuid, 'condition', e.target.value)}
                        >
                          {CONDITIONS.map((cond) => <option key={cond.value} value={cond.value}>{cond.label}</option>)}
                        </select>
                      </td>
                      <td className="px-4 py-2">
                        <input
                          type="number"
                          min={1}
                          aria-label={`Quantity for ${c.name}`}
                          className="w-16 border-2 border-black rounded px-2 py-1 text-xs bg-card"
                          value={c.quantity}
                          onChange={(e) => updateCard(c.uuid, 'quantity', e.target.value)}
                        />
                      </td>
                      <td className="px-4 py-2">
                        <button
                          type="button"
                          aria-label={`Remove ${c.name}`}
                          className="text-xs underline"
                          onClick={() => removeCard(c.uuid)}
                        >
                          Remove
                        </button>
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          )}
        </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>
      )}

Adaptation note: match whatever the existing file's exact JSX/state variable names are (email, name, rawText, game, submit, submitting) โ€” the snippets above assume those names from Task 9's Phase 2 build; if a name differs, use the real one, don't rename the existing code to match this snippet.

  • [ ] 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 (existing 2 tests + 2 new)

  • [ ] Step 5: 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 6: Commit
bash
git add client/src/pages/buylist/NewBuyInTab.jsx client/src/pages/buylist/NewBuyInTab.test.jsx
git commit -m "Let merchants build a buy order by searching and selecting cards"

Task 6: 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/claude/buylist-phase-2 | grep -n "|| 'mtg'\|?? 'mtg'"   # zero matches (ยง5.5)
grep -rn "myshopify.com" server/services/buylistQuoteService.js server/schemas/buylistOrder.js client/src/pages/buylist/CardSearchPicker.jsx  # 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 โ†’ confirm it opens in "Search & Select" mode by default โ†’ search a real MTG card, add it, adjust its condition/finish/quantity, submit, confirm it lands on the Review screen with a correct price. Switch to "Paste List" mode and confirm the existing free-text flow still works unchanged. Repeat the search-and-select flow once for Pokemon โ€” since Pokemon's non-unique join-key issue was specifically what motivated this whole feature, confirm the selector path never has that ambiguity (the merchant picks the exact card from search results, so there's nothing to get wrong).

  • [ ] Step 3: Open the PR

Check whether Phase 2's PR (#362) has merged by now (gh pr view 362 --json state). If merged, rebase this branch onto main first and retarget; if still open, target this PR at claude/buylist-phase-2.

bash
git push -u origin claude/buylist-selector-entry
gh pr create --base claude/buylist-phase-2 --title "Let merchants build a buy order by searching the catalog instead of only pasting text" --body "$(cat <<'EOF'
Direct response to feedback on Phase 2's free-text-only entry (#362): "why
not a list of selectors where you craft the order?"

- New search-and-select flow in New Buy-In: search the catalog, pick an
  exact card (name/set/rarity unambiguous by construction), set its
  finish/condition/quantity, add to a running list, submit
- Free-text paste stays available as a secondary "Paste List" mode for bulk
  imports from an existing TCGPlayer/ManaBox export
- Server never trusts client-echoed card name/set/rarity for a
  selector-added line โ€” only `cardUuid` is treated as merchant intent;
  everything else is re-derived from a fresh catalog lookup
  (`findCardByUuid`), so this path has zero exposure to the matching-
  ambiguity bug classes the free-text parser needed four review rounds to
  close (ReDoS, non-unique join keys, wrong-printing substitution)

Game parity (ยง5.1): `findCardByUuid`/`priceIdentifiedLine`/`priceSelectedLines`
are plugin-driven by construction โ€” mtg, pokemon, and riftbound all resolve
through the same `plugin.getProductModel()` call the free-text matcher
already uses, no per-game branching.

Stacks on #362 (Phase 2) โ€” merge that first, then retarget this PR at `main`.

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"

Self-review (done at plan time) โ€‹

  • Spec coverage: search-and-select entry โœ… (Tasks 4โ€“5), server-side re-derivation of pricing-relevant fields from cardUuid only โœ… (Task 1, called out explicitly in Global Constraints), either/or request body โœ… (Task 2), paste retained as secondary mode โœ… (Task 5), no changes needed to computeOrderTotals/persistence/Review screen โœ… (verified both paths produce identical BuylistOrder line shape).
  • Type/name consistency: findCardByUuid/priceIdentifiedLine/priceSelectedLines function names and signatures match exactly across Tasks 1 (definition), 3 (route usage), and the Global Constraints section. The selector line shape {cardUuid, finish, condition, quantity} matches across Task 1's tests, Task 2's schema, and Task 5's client submit body.
  • Known adaptation points (flagged inline with target behavior stated): the exact JSX/state variable names in NewBuyInTab.jsx (Task 5, since this modifies Phase 2's existing file rather than creating a new one) and the test's row-selector syntax (Task 5).
  • Grounding: catalog response shape, existing debounce pattern, getDefaultFinish/hasMultipleFinishes exports, and the absence of any discriminated-union precedent were all verified against real files during planning (cited inline), not assumed.