Skip to content

Buylist Import Connectors (ManaBox + TCGplayer CSV) 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 generate a Smart Buylist offer from a ManaBox collection-export CSV or a TCGplayer app collection-export CSV, in addition to the existing free-text paste, by adding two new format-specific parsers that feed the same match/price/rate pipeline parseCardList already uses.

Architecture: Extend, don't duplicate (CLAUDE.md ยง5.9): generateQuote gains an optional format parameter ('plain' | 'manabox' | 'tcgplayer', default 'plain') that selects which parser turns raw input into the existing ParsedLine shape ({ rawLine, quantity, name, setCode, collectorNumber, condition }). Everything downstream of that shape โ€” matchCatalogLine, resolveBasisPrice, applyBuylistRate, priceAndRateLine, computeOrderTotals โ€” is untouched and already game-agnostic (plugin-driven via plugin.getProductModel()), so no per-game branching is added anywhere in this plan.

Tech Stack: Node.js/Express, Zod, csv-parser (already a runtime dependency, currently used only by server/scripts/data-loading/* โ€” this plan is its first use inside server/services/), React 18 + retroui, Vitest.

Global Constraints โ€‹

This is a "no live sample file" plan โ€” the source spec (docs/superpowers/specs/2026-07-14-smart-buylist-customer-intake-design.md, "Independent track โ€” structured import formats") explicitly blocks starting this work without a real sample export, which normally would mean stopping here. Per direction from the user (2026-07-18), the format was instead grounded through public documentation research. Every literal below is cited; residual unknowns are called out explicitly and the parsers are designed to degrade safely rather than guess (CLAUDE.md ยง5.4 โ€” never invent a literal value).

ManaBox CSV โ€” confirmed, comma-delimited, header row:Name,Set Code,Set Name,Collector Number,Foil,Rarity,Quantity,ManaBox ID,Scryfall ID,Purchase Price,Misprint,Altered,Condition,Language,Purchase Price Currency Sources: ManaBox's own guide (https://www.manabox.app/guides/collection/import-export/) lists these exact column names; independently cross-verified against a real user-quoted export header posted on a public forum (https://tappedout.net/mtg-forum/tappedout/importing-csv-from-manabox/, a support-thread poster pasting their actual ManaBox CSV header row verbatim). Two independent sources agree on name, order, and count (15 columns) โ€” this is as grounded as web research gets without an in-hand file.

TCGplayer CSV โ€” confirmed, comma-delimited, header row (reference-only + editable fields):TCGplayer Id, Product Line, Set Name, Product Name, Title, Number, Rarity, Condition, TCG Market Price, TCG Direct Low, TCG Low w/ Shipping, TCG Low Price, Total Quantity, Pending Quantity, Photo URL, Add to Quantity, TCG Marketplace Price, My Store Reserve Quantity, My Store Price Source: TCGplayer's own official Zendesk article, "Importing and Exporting CSVs to Mass Update Prices and Quantities" (https://help.tcgplayer.com/hc/en-us/articles/115002358027) โ€” its "CSV Headings Glossary" section names every column verbatim. This is the same export a customer produces from a scanned "My Collection" list per the companion article "How to Import Lists of Cards from the TCGplayer Mobile App to Inventory" (https://help.tcgplayer.com/hc/en-us/articles/17474327281303): scan in the app โ†’ "Import to Seller Portal" โ†’ Pricing tab โ†’ "Export from Live"/"Export Filtered CSV" produces this exact file. Critically, the glossary itself states these fields are "needed to accurately import your Buylist requests" โ€” confirming this is the right format for this feature, not an assumption.

TCGplayer condition vocabulary โ€” confirmed verbatim: "Near Mint, Lightly Played, Moderately Played, and the same for foils" (same source). Read as: "<Condition Name>" or "<Condition Name> Foil".

Explicitly NOT invented โ€” scoped around instead of guessed:

  • ManaBox's literal Foil and Condition values are not stated by either source (column names are confirmed; column contents are not). Rather than guess an enum and risk silently matching nothing (ยง5.4's exact failure class), this plan:
    • Drops the Foil column entirely. The existing plain-text parseCardList path never accepts an explicit finish/foil signal either โ€” finish is always re-derived from the matched catalog doc (matchCatalogLine's variant = doc.variants[0]). The CSV connectors match that existing behavior exactly; this is parity, not a regression.
    • Reuses normalizeCondition's existing safe-fallback design (any unrecognized token โ†’ 'nm', already shipped and tested), extended with the full English condition words TCGplayer's docs did confirm ("Near Mint", "Lightly Played", "Moderately Played", "Heavily Played", "Damaged"). Whatever ManaBox actually writes either matches one of these confirmed words or safely falls back to 'nm' โ€” never a crash, never a silent wrong-price match.
  • CSV delimiter: assumed standard comma (both sources call it "CSV"; the forum poster's |-joined text is their own readability formatting when pasting a header into a forum post, not evidence of a literal pipe delimiter).
  • TCGplayer's Number column exact formatting (leading zeros, promo suffixes) is unconfirmed. No special handling is added โ€” matchCatalogLine's existing set-code+collector-number lookup will simply fail to find a candidate if the format differs, and its existing name-only fallback (case-insensitive exact match) engages automatically, exactly as it already does for any line where set/number don't resolve. No new risk introduced.

Recommendation carried forward, not blocking: get one real sample file from each source before this ships to a merchant in production, and diff it against the column lists above. That verification is a follow-up, not a task in this plan, per the user's direction to proceed from research.

Other constraints (existing, reaffirmed for this diff):

  • game is never defaulted (ยง5.5) โ€” connectors don't touch game at all; it continues to flow from the existing ?game= query param, untouched.
  • No new Mongoose sub-document fields โ€” BuylistOrder.lines[]'s shape is unchanged (ยง5.2 round-trip-test requirement doesn't apply; nothing new is persisted).
  • No new aggregation pipelines (ยง5.6 doesn't apply).
  • csv-parser is already a runtime dependency (package.json, ^3.2.0) โ€” no new dependency added.
  • Server code is CommonJS; test files are ESM, matching every other file in this directory.
  • DI pattern: _setDeps/_resetDeps/getDeps(), exactly as the rest of buylistQuoteService.js already does.
  • Coverage โ‰ฅ70% on all four metrics for every modified file; npm run lint clean.
  • Commit messages: one imperative sentence, merchant-visible outcome, sentence case, no trailing period.
  • Game parity (ยง5.1): stated per-task below. Summary โ€” no plugin code touched; ManaBox is MTG-only by the product's own design (it's a Magic-only scanner app, not a code limitation on our side, so merchants naturally select game=mtg when using it); TCGplayer's CSV spans product lines and works identically for mtg/pokemon/riftbound since column mapping never branches on game.

Task 1: Generic CSV-string-to-rows helper โ€‹

Files:

  • Modify: server/services/buylistQuoteService.js:11 (add requires after 'use strict';), and add parseCsvRows near the top-level helpers (after the existing regex/helper block, before parseCardList at line 141).
  • Test: server/services/buylistQuoteService.test.js (new describe('parseCsvRows', ...) block, add near the top after existing imports).

Interfaces:

  • Produces: parseCsvRows(csvText: string): Promise<Array<Record<string, string>>> โ€” rows keyed by the CSV's own header row, values as raw strings (no type coercion). Used by Tasks 2 and 3.

  • [ ] Step 1: Write the failing tests

Add to server/services/buylistQuoteService.test.js, right after the existing import line at the top of the file:

javascript
import { parseCsvRows } from './buylistQuoteService.js';

(Add parseCsvRows to the existing named-import list on line 2 rather than a second import line โ€” the file currently imports parseCardList, matchCatalogLine, resolveBasisPrice, applyBuylistRate, computeOrderTotals, generateQuote, findCardByUuid, priceIdentifiedLine, priceSelectedLines, _setDeps, _resetDeps from './buylistQuoteService.js'; add parseCsvRows to that list.)

Then add this new describe block anywhere at the top level of the file (e.g. right before describe('parseCardList', ...)):

javascript
describe('parseCsvRows', () => {
    it('parses a simple two-column CSV into row objects keyed by header', async () => {
        const rows = await parseCsvRows('Name,Quantity\nLightning Bolt,4\n');
        expect(rows).toEqual([{ Name: 'Lightning Bolt', Quantity: '4' }]);
    });

    it('handles a quoted field containing a comma', async () => {
        const rows = await parseCsvRows('Name,Set Code\n"Ragavan, Nimble Pilferer",MH2\n');
        expect(rows).toEqual([{ Name: 'Ragavan, Nimble Pilferer', 'Set Code': 'MH2' }]);
    });

    it('returns an empty array for a header-only CSV', async () => {
        const rows = await parseCsvRows('Name,Quantity\n');
        expect(rows).toEqual([]);
    });

    it('returns an empty array for an empty string', async () => {
        const rows = await parseCsvRows('');
        expect(rows).toEqual([]);
    });
});
  • [ ] Step 2: Run the tests to verify they fail

Run: npm test -- buylistQuoteService.test.js -t parseCsvRows Expected: FAIL โ€” parseCsvRows is not a function (not exported yet).

  • [ ] Step 3: Implement parseCsvRows

In server/services/buylistQuoteService.js, change line 11 from:

javascript
'use strict';

to:

javascript
'use strict';

const { Readable } = require('stream');
const csv = require('csv-parser');

Then add this function after the LINE_PLAIN_RE / normalizeCondition / splitQuantityPrefix helper block and before function parseCardList(rawText) { (i.e. insert immediately before line 141):

javascript
// Wraps the existing csv-parser dependency (already used by
// server/scripts/data-loading/*) for an in-memory CSV string instead of a
// file stream โ€” Readable.from([csvText]) emits the whole string as one
// chunk, so csv-parser sees it exactly as it would a real file. Using the
// real CSV-quoting-aware parser here (not a hand-rolled split(',')) matters
// concretely: MTG card names can contain commas (e.g. "Ragavan, Nimble
// Pilferer"), which a naive split would silently corrupt.
function parseCsvRows(csvText) {
    return new Promise((resolve, reject) => {
        const rows = [];
        Readable.from([String(csvText || '')])
            .pipe(csv())
            .on('data', (row) => rows.push(row))
            .on('end', () => resolve(rows))
            .on('error', reject);
    });
}

Finally, add parseCsvRows to the module.exports block at the bottom of the file (currently starting at line 509) โ€” add it as a new line in that object:

javascript
module.exports = {
    parseCardList,
    parseCsvRows,
    matchCatalogLine,
    resolveBasisPrice,
    applyBuylistRate,
    computeOrderTotals,
    generateQuote,
    findCardByUuid,
    priceIdentifiedLine,
    priceSelectedLines,
    _setDeps,
    _resetDeps
};
  • [ ] Step 4: Run the tests to verify they pass

Run: npm test -- buylistQuoteService.test.js -t parseCsvRows Expected: PASS (4 tests).

  • [ ] Step 5: Commit
bash
git add server/services/buylistQuoteService.js server/services/buylistQuoteService.test.js
git commit -m "Add a CSV-string parsing helper for buylist import connectors"

Task 2: ManaBox CSV connector โ€‹

Files:

  • Modify: server/services/buylistQuoteService.js:102-105 (extend normalizeCondition), and add parseManaBoxCsv after parseCardList (after line ~178, before the const defaultPlugins = require('../plugins'); block currently at line 182).
  • Test: server/services/buylistQuoteService.test.js (new describe('parseManaBoxCsv', ...) block).

Interfaces:

  • Consumes: parseCsvRows (Task 1).

  • Produces: parseManaBoxCsv(csvText: string): Promise<ParsedLine[]> where ParsedLine = { rawLine, quantity, name, setCode, collectorNumber, condition } โ€” the exact shape parseCardList already produces and matchCatalogLine/priceAndRateLine already consume. Used by Task 4.

  • [ ] Step 1: Write the failing tests

Add parseManaBoxCsv to the named-import list at the top of server/services/buylistQuoteService.test.js (same import statement touched in Task 1).

Add this describe block after the existing describe('parseCardList', ...) block:

javascript
describe('parseManaBoxCsv', () => {
    const HEADER = 'Name,Set Code,Set Name,Collector Number,Foil,Rarity,Quantity,ManaBox ID,Scryfall ID,Purchase Price,Misprint,Altered,Condition,Language,Purchase Price Currency';

    it('parses a row into the same shape parseCardList produces', async () => {
        const csvText = `${HEADER}\n"Ragavan, Nimble Pilferer",MH2,Modern Horizons 2,138,false,mythic,1,mb-1,sf-1,12.50,false,false,Near Mint,en,USD\n`;
        const [line] = await parseManaBoxCsv(csvText);
        expect(line).toEqual({
            rawLine: '1x Ragavan, Nimble Pilferer [MH2] 138',
            quantity: 1,
            name: 'Ragavan, Nimble Pilferer',
            setCode: 'MH2',
            collectorNumber: '138',
            condition: 'nm'
        });
    });

    it('parses quantities greater than 1', async () => {
        const csvText = `${HEADER}\nOrcish Bowmasters,LTR,The Lord of the Rings,103,false,rare,4,mb-2,sf-2,1.00,false,false,Near Mint,en,USD\n`;
        const [line] = await parseManaBoxCsv(csvText);
        expect(line.quantity).toBe(4);
    });

    it('normalizes each confirmed TCGplayer/ManaBox condition word case-insensitively', async () => {
        const rows = [
            ['Near Mint', 'nm'],
            ['lightly played', 'lp'],
            ['Moderately Played', 'mp'],
            ['HEAVILY PLAYED', 'hp'],
            ['Damaged', 'damaged']
        ];
        const csvText = HEADER + '\n' + rows.map(([cond], i) =>
            `Card ${i},LTR,The Lord of the Rings,${i},false,common,1,mb-${i},sf-${i},0.10,false,false,${cond},en,USD`
        ).join('\n') + '\n';
        const lines = await parseManaBoxCsv(csvText);
        lines.forEach((line, i) => expect(line.condition).toBe(rows[i][1]));
    });

    it('falls back to nm for an unrecognized condition value rather than throwing', async () => {
        const csvText = `${HEADER}\nSome Card,LTR,The Lord of the Rings,1,false,common,1,mb-9,sf-9,0.10,false,false,SomeUnknownValue,en,USD\n`;
        const [line] = await parseManaBoxCsv(csvText);
        expect(line.condition).toBe('nm');
    });

    it('skips rows with a blank Name', async () => {
        const csvText = `${HEADER}\n,LTR,The Lord of the Rings,1,false,common,1,mb-9,sf-9,0.10,false,false,Near Mint,en,USD\n`;
        const lines = await parseManaBoxCsv(csvText);
        expect(lines).toHaveLength(0);
    });

    it('returns an empty array for an empty CSV', async () => {
        expect(await parseManaBoxCsv('')).toEqual([]);
    });
});
  • [ ] Step 2: Run the tests to verify they fail

Run: npm test -- buylistQuoteService.test.js -t parseManaBoxCsv Expected: FAIL โ€” parseManaBoxCsv is not a function.

  • [ ] Step 3: Extend normalizeCondition and implement parseManaBoxCsv

In server/services/buylistQuoteService.js, replace the existing normalizeCondition function (lines 102-105):

javascript
function normalizeCondition(raw) {
    if (!raw) return 'nm';
    const key = raw.toLowerCase();
    return VALID_CONDITIONS.includes(key) ? key : 'nm';
}

with:

javascript
// Full-English-word aliases, sourced from TCGplayer's own condition
// glossary ("Near Mint, Lightly Played, Moderately Played, and the same
// for foils" โ€” help.tcgplayer.com/hc/en-us/articles/115002358027). Used by
// the CSV connectors (parseManaBoxCsv, parseTcgplayerCsv), which receive
// full words instead of the plain-text parser's parenthesized abbreviation
// tokens. Any value not in VALID_CONDITIONS or this map โ€” including
// whatever ManaBox's actual Condition column turns out to contain, which
// no public source documents โ€” falls through to the existing 'nm' default
// below, never a thrown error or a silent wrong-condition match.
const CONDITION_ALIASES = {
    'near mint': 'nm',
    'lightly played': 'lp',
    'moderately played': 'mp',
    'heavily played': 'hp',
    'damaged': 'damaged'
};

function normalizeCondition(raw) {
    if (!raw) return 'nm';
    const key = raw.toLowerCase().trim();
    if (VALID_CONDITIONS.includes(key)) return key;
    return CONDITION_ALIASES[key] || 'nm';
}

Then add this block after function parseCardList(rawText) { ... } ends (after line ~178) and before const defaultPlugins = require('../plugins');:

javascript
// ManaBox collection-export CSV. Column names verified against ManaBox's
// own docs (manabox.app/guides/collection/import-export/) and cross-checked
// against a real user-quoted export header on a support forum โ€” see this
// plan's Global Constraints for citations and for why the Foil column is
// intentionally not read (matches parseCardList's existing no-explicit-
// finish behavior).
function parseManaBoxRow(row) {
    const quantity = parseInt(row['Quantity'], 10);
    const name = (row['Name'] || '').trim();
    const setCode = (row['Set Code'] || '').trim();
    const collectorNumber = (row['Collector Number'] || '').trim();
    return {
        rawLine: `${row['Quantity']}x ${name}${setCode ? ` [${setCode}]` : ''}${collectorNumber ? ` ${collectorNumber}` : ''}`,
        quantity: Number.isFinite(quantity) && quantity > 0 ? quantity : 1,
        name,
        setCode: setCode ? setCode.toUpperCase() : null,
        collectorNumber: collectorNumber || null,
        condition: normalizeCondition(row['Condition'])
    };
}

async function parseManaBoxCsv(csvText) {
    const rows = await parseCsvRows(csvText);
    return rows
        .slice(0, MAX_LINES)
        .filter((row) => row['Name'] && row['Name'].trim())
        .map(parseManaBoxRow);
}
  • [ ] Step 4: Run the tests to verify they pass

Run: npm test -- buylistQuoteService.test.js Expected: PASS โ€” all parseCardList tests (unaffected by the alias-map addition, since VALID_CONDITIONS.includes(key) is still checked first) plus all new parseManaBoxCsv tests.

  • [ ] Step 5: Commit
bash
git add server/services/buylistQuoteService.js server/services/buylistQuoteService.test.js
git commit -m "Add a ManaBox CSV import connector to Smart Buylist"

Task 3: TCGplayer CSV connector โ€‹

Files:

  • Modify: server/services/buylistQuoteService.js โ€” add parseTcgplayerCsv right after parseManaBoxCsv (Task 2).
  • Test: server/services/buylistQuoteService.test.js (new describe('parseTcgplayerCsv', ...) block).

Interfaces:

  • Consumes: parseCsvRows (Task 1), normalizeCondition (extended in Task 2).

  • Produces: parseTcgplayerCsv(csvText: string): Promise<ParsedLine[]> โ€” same shape as Task 2. Used by Task 4.

  • [ ] Step 1: Write the failing tests

Add parseTcgplayerCsv to the named-import list (same statement touched in Tasks 1-2).

Add this describe block after describe('parseManaBoxCsv', ...):

javascript
describe('parseTcgplayerCsv', () => {
    const HEADER = 'TCGplayer Id,Product Line,Set Name,Product Name,Title,Number,Rarity,Condition,TCG Market Price,TCG Direct Low,TCG Low w/ Shipping,TCG Low Price,Total Quantity,Pending Quantity,Photo URL,Add to Quantity,TCG Marketplace Price,My Store Reserve Quantity,My Store Price';

    it('parses a row into the same shape parseCardList produces, leaving setCode null', async () => {
        const csvText = `${HEADER}\n123456,Magic,Modern Horizons 2,"Ragavan, Nimble Pilferer",,138,Mythic,Near Mint,45.00,40.00,42.00,41.00,1,0,,0,45.00,,\n`;
        const [line] = await parseTcgplayerCsv(csvText);
        expect(line).toEqual({
            rawLine: '1x Ragavan, Nimble Pilferer (Modern Horizons 2) #138',
            quantity: 1,
            name: 'Ragavan, Nimble Pilferer',
            setCode: null,
            collectorNumber: '138',
            condition: 'nm'
        });
    });

    it('reads quantity from Total Quantity, not Add to Quantity', async () => {
        const csvText = `${HEADER}\n123457,Magic,The Lord of the Rings,Orcish Bowmasters,,103,Rare,Near Mint,5.00,4.00,4.50,4.20,7,0,,-2,5.00,,\n`;
        const [line] = await parseTcgplayerCsv(csvText);
        expect(line.quantity).toBe(7);
    });

    it('strips a trailing "Foil" suffix before normalizing condition', async () => {
        const csvText = `${HEADER}\n123458,Magic,The Lord of the Rings,Test Card,,1,Common,Lightly Played Foil,1.00,0.80,0.90,0.85,2,0,,0,1.00,,\n`;
        const [line] = await parseTcgplayerCsv(csvText);
        expect(line.condition).toBe('lp');
    });

    it('falls back to nm for an unrecognized condition value', async () => {
        const csvText = `${HEADER}\n123459,Magic,The Lord of the Rings,Test Card 2,,2,Common,Unopened,1.00,0.80,0.90,0.85,2,0,,0,1.00,,\n`;
        const [line] = await parseTcgplayerCsv(csvText);
        expect(line.condition).toBe('nm');
    });

    it('skips rows with a blank Product Name', async () => {
        const csvText = `${HEADER}\n123460,Magic,The Lord of the Rings,,,3,Common,Near Mint,1.00,0.80,0.90,0.85,2,0,,0,1.00,,\n`;
        const lines = await parseTcgplayerCsv(csvText);
        expect(lines).toHaveLength(0);
    });

    it('returns an empty array for an empty CSV', async () => {
        expect(await parseTcgplayerCsv('')).toEqual([]);
    });
});
  • [ ] Step 2: Run the tests to verify they fail

Run: npm test -- buylistQuoteService.test.js -t parseTcgplayerCsv Expected: FAIL โ€” parseTcgplayerCsv is not a function.

  • [ ] Step 3: Implement parseTcgplayerCsv

Add this block after parseManaBoxCsv in server/services/buylistQuoteService.js:

javascript
// TCGplayer app collection-export CSV. Column names and condition
// vocabulary verified verbatim against TCGplayer's own official glossary
// (help.tcgplayer.com/hc/en-us/articles/115002358027), which explicitly
// says these fields are used "to accurately import your Buylist requests."
// There is no set-CODE column (only Set Name) -- setCode is left null and
// matchCatalogLine's existing case-insensitive name-only fallback handles
// resolution, exactly as it already does for any plain-text line with no
// bracketed set code.
function stripFoilSuffix(condition) {
    return String(condition || '').replace(/\s*foil\s*$/i, '').trim();
}

function parseTcgplayerRow(row) {
    const quantity = parseInt(row['Total Quantity'], 10);
    const name = (row['Product Name'] || '').trim();
    const setName = (row['Set Name'] || '').trim();
    const number = (row['Number'] || '').trim();
    return {
        rawLine: `${row['Total Quantity']}x ${name}${setName ? ` (${setName})` : ''}${number ? ` #${number}` : ''}`,
        quantity: Number.isFinite(quantity) && quantity > 0 ? quantity : 1,
        name,
        setCode: null,
        collectorNumber: number || null,
        condition: normalizeCondition(stripFoilSuffix(row['Condition']))
    };
}

async function parseTcgplayerCsv(csvText) {
    const rows = await parseCsvRows(csvText);
    return rows
        .slice(0, MAX_LINES)
        .filter((row) => row['Product Name'] && row['Product Name'].trim())
        .map(parseTcgplayerRow);
}

Add parseManaBoxCsv and parseTcgplayerCsv to module.exports:

javascript
module.exports = {
    parseCardList,
    parseCsvRows,
    parseManaBoxCsv,
    parseTcgplayerCsv,
    matchCatalogLine,
    resolveBasisPrice,
    applyBuylistRate,
    computeOrderTotals,
    generateQuote,
    findCardByUuid,
    priceIdentifiedLine,
    priceSelectedLines,
    _setDeps,
    _resetDeps
};
  • [ ] Step 4: Run the tests to verify they pass

Run: npm test -- buylistQuoteService.test.js Expected: PASS โ€” full file green.

  • [ ] Step 5: Commit
bash
git add server/services/buylistQuoteService.js server/services/buylistQuoteService.test.js
git commit -m "Add a TCGplayer CSV import connector to Smart Buylist"

Task 4: Wire format into generateQuote โ€‹

Files:

  • Modify: server/services/buylistQuoteService.js:396 (generateQuote) and its getDeps() default deps object (around line 182-200).
  • Test: server/services/buylistQuoteService.test.js (extend the existing describe('generateQuote', ...) block โ€” search for it; it already exists per the file's test coverage of Phase 2).

Interfaces:

  • Consumes: parseManaBoxCsv, parseTcgplayerCsv (Tasks 2-3), parseCardList (existing).

  • Produces: generateQuote({ store, game, rawText, format }, deps): Promise<{ lines: Line[] }> โ€” format is optional, defaults to 'plain'. Backward compatible: every existing caller that omits format behaves identically to today.

  • [ ] Step 1: Write the failing test

Find the existing describe('generateQuote', ...) block in server/services/buylistQuoteService.test.js and add these two tests inside it:

javascript
it('dispatches to parseManaBoxCsv when format is manabox', async () => {
    const fakeParsedLine = { rawLine: 'fake', quantity: 1, name: 'Fake Card', setCode: null, collectorNumber: null, condition: 'nm' };
    const fakeMatch = { matchStatus: 'matched', cardUuid: 'uuid-1', cardName: 'Fake Card', setCode: 'ABC', collectorNumber: '1', rarity: 'common', finish: 'Nonfoil' };
    const parseManaBoxCsvMock = vi.fn().mockResolvedValue([fakeParsedLine]);
    _setDeps({
        getGameBuylistConfig: () => ({ priceBasis: 'market', conditionMultipliers: {}, defaultRate: 0.5, storeCreditBonus: 0.1 }),
        parseManaBoxCsv: parseManaBoxCsvMock,
        parseTcgplayerCsv: vi.fn(),
        parseCardList: vi.fn(),
        matchCatalogLine: vi.fn().mockResolvedValue(fakeMatch),
        resolveBasisPrice: vi.fn().mockResolvedValue(10),
        applyBuylistRate: vi.fn().mockReturnValue({ offerPrice: 5, ruleApplied: 'default' })
    });

    const result = await generateQuote({ store: {}, game: 'mtg', rawText: 'csv,text', format: 'manabox' });

    expect(parseManaBoxCsvMock).toHaveBeenCalledWith('csv,text');
    expect(result.lines).toHaveLength(1);
    expect(result.lines[0].matchStatus).toBe('matched');
});

it('dispatches to parseCardList when format is omitted (default plain)', async () => {
    const parseCardListMock = vi.fn().mockReturnValue([]);
    _setDeps({
        getGameBuylistConfig: () => ({ priceBasis: 'market', conditionMultipliers: {}, defaultRate: 0.5, storeCreditBonus: 0.1 }),
        parseManaBoxCsv: vi.fn(),
        parseTcgplayerCsv: vi.fn(),
        parseCardList: parseCardListMock,
        matchCatalogLine: vi.fn(),
        resolveBasisPrice: vi.fn(),
        applyBuylistRate: vi.fn()
    });

    await generateQuote({ store: {}, game: 'mtg', rawText: 'plain text' });

    expect(parseCardListMock).toHaveBeenCalledWith('plain text');
});

Add afterEach(() => _resetDeps()); if the surrounding describe('generateQuote', ...) block doesn't already have one โ€” check first; the file's top-level test setup likely already resets deps between tests per the DI pattern used throughout.

  • [ ] Step 2: Run the tests to verify they fail

Run: npm test -- buylistQuoteService.test.js -t "dispatches to" Expected: FAIL โ€” format is ignored, so parseCardList (not parseManaBoxCsv) is called for the first test, and the mock assertion fails.

  • [ ] Step 3: Implement the dispatch

In server/services/buylistQuoteService.js, change generateQuote (currently starting at line 396):

javascript
async function generateQuote({ store, game, rawText }, deps = getDeps()) {
    const buylistConfig = deps.getGameBuylistConfig(store, game);
    const parsedLines = deps.parseCardList(rawText);

to:

javascript
async function generateQuote({ store, game, rawText, format = 'plain' }, deps = getDeps()) {
    const buylistConfig = deps.getGameBuylistConfig(store, game);
    const parsedLines = await (
        format === 'manabox' ? deps.parseManaBoxCsv(rawText)
            : format === 'tcgplayer' ? deps.parseTcgplayerCsv(rawText)
                : deps.parseCardList(rawText)
    );

(the rest of the function body is unchanged โ€” await on the already-synchronous parseCardList(rawText) array is a no-op, since await on a non-promise value resolves immediately).

Then in getDeps()'s default object (the block starting around line 182-200 that currently includes parseCardList, matchCatalogLine, resolveBasisPrice, applyBuylistRate, findCardByUuid, priceIdentifiedLine), add the two new parsers:

javascript
        parseCardList,
        parseManaBoxCsv,
        parseTcgplayerCsv,
        matchCatalogLine,

(insert parseManaBoxCsv, and parseTcgplayerCsv, immediately after the existing parseCardList, line in that object).

  • [ ] Step 4: Run the tests to verify they pass

Run: npm test -- buylistQuoteService.test.js Expected: PASS โ€” full file green, including all pre-existing generateQuote tests (unaffected โ€” they all omit format, which now defaults to 'plain', identical to today's only behavior).

  • [ ] Step 5: Commit
bash
git add server/services/buylistQuoteService.js server/services/buylistQuoteService.test.js
git commit -m "Let generateQuote dispatch to the ManaBox and TCGplayer CSV connectors"

Task 5: Zod schema + route wiring โ€‹

Files:

  • Modify: server/schemas/buylistOrder.js:21-32 (createBuylistOrderSchema).
  • Modify: server/routes/api.js:1902-1955 (POST /api/buylist/orders handler, specifically the generateQuote call at line 1927-1929).
  • Test: server/schemas/schemas.test.js (extend the existing buylist-order schema tests โ€” search for createBuylistOrderSchema).

Interfaces:

  • Consumes: generateQuote's new format parameter (Task 4).

  • Produces: createBuylistOrderSchema now accepts an optional format field; POST /api/buylist/orders passes it through.

  • [ ] Step 1: Write the failing tests

In server/schemas/schemas.test.js, find the existing tests for createBuylistOrderSchema and add:

javascript
it('defaults format to plain when omitted', () => {
    const result = createBuylistOrderSchema.parse({
        customer: { email: 'a@b.com' },
        rawText: '1 Lightning Bolt [CLB] 187'
    });
    expect(result.format).toBe('plain');
});

it('accepts format: manabox', () => {
    const result = createBuylistOrderSchema.parse({
        customer: { email: 'a@b.com' },
        rawText: 'Name,Quantity\nLightning Bolt,1\n',
        format: 'manabox'
    });
    expect(result.format).toBe('manabox');
});

it('rejects an unsupported format value', () => {
    expect(() => createBuylistOrderSchema.parse({
        customer: { email: 'a@b.com' },
        rawText: '1 Lightning Bolt [CLB] 187',
        format: 'cardkingdom'
    })).toThrow();
});
  • [ ] Step 2: Run the tests to verify they fail

Run: npm test -- schemas.test.js -t "format" Expected: FAIL โ€” createBuylistOrderSchema currently has .strict(), so a format key is rejected outright rather than defaulted/validated.

  • [ ] Step 3: Extend the schema and wire the route

In server/schemas/buylistOrder.js, change createBuylistOrderSchema (lines 21-32):

javascript
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' }
);

to:

javascript
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(),
    // 'plain' = free-text paste (parseCardList); 'manabox'/'tcgplayer' = the
    // matching CSV export connector (buylistQuoteService.js). Only
    // meaningful alongside rawText -- ignored on the lines path.
    format: z.enum(['plain', 'manabox', 'tcgplayer']).optional().default('plain'),
    lines: z.array(selectedCardLineSchema).min(1, 'lines must not be empty').max(500, 'too many lines').optional(),
    payoutMethod: z.enum(['store_credit', 'cash']).optional()
}).strict().refine(
    (data) => (data.rawText != null) !== (data.lines != null),
    { message: 'Provide either rawText or lines, but not both' }
);

In server/routes/api.js, change the generateQuote call (currently lines 1927-1929):

javascript
        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 });

to:

javascript
        const quote = parsed.data.rawText
            ? await generateQuote({ store: req.store, game, rawText: parsed.data.rawText, format: parsed.data.format })
            : await priceSelectedLines({ store: req.store, game, lines: parsed.data.lines });
  • [ ] Step 4: Run the tests to verify they pass

Run: npm test -- schemas.test.js Expected: PASS โ€” full schema test file green.

Also run the full server suite once here, since this task touches both a schema and a route: Run: npm test Expected: PASS โ€” no regressions in buylistQuoteService.test.js or elsewhere.

  • [ ] Step 5: Commit
bash
git add server/schemas/buylistOrder.js server/routes/api.js server/schemas/schemas.test.js
git commit -m "Accept a format field on buy order creation for CSV import connectors"

Task 6: Client โ€” format selector and file upload in New Buy-In โ€‹

Files:

  • Modify: client/src/pages/buylist/NewBuyInTab.jsx (state, submit body, and the paste-mode JSX block at the bottom of the component).
  • Modify: client/src/pages/buylist/NewBuyInTab.test.jsx (update the existing paste-mode test's expected request body; add new tests for format selection and file upload).

Interfaces:

  • Consumes: POST /buylist/orders?game=<id> now accepting format (Task 5).

  • Produces: no new exports โ€” this is a leaf page component.

  • [ ] Step 1: Update the existing test and write the new failing tests

In client/src/pages/buylist/NewBuyInTab.test.jsx, the first test ('submits the pasted list and navigates to the new order') currently asserts:

javascript
    await waitFor(() => expect(api.post).toHaveBeenCalledWith(
      '/buylist/orders?game=mtg',
      { customer: { email: 'jamie@example.com' }, rawText: '1 Lightning Bolt [CLB] 187' }
    ));

Change it to include the new format field (this test now covers the plain-text default explicitly):

javascript
    await waitFor(() => expect(api.post).toHaveBeenCalledWith(
      '/buylist/orders?game=mtg',
      { customer: { email: 'jamie@example.com' }, rawText: '1 Lightning Bolt [CLB] 187', format: 'plain' }
    ));

Then add these two new tests inside the top-level describe('NewBuyInTab', ...) block, after the existing two tests:

javascript
  it('lets the merchant choose the ManaBox CSV format', async () => {
    render(<MemoryRouter><NewBuyInTab /></MemoryRouter>);
    fireEvent.change(await screen.findByLabelText(/customer email/i), { target: { value: 'jamie@example.com' } });
    fireEvent.click(screen.getByRole('button', { name: /paste list/i }));
    fireEvent.change(screen.getByLabelText(/import format/i), { target: { value: 'manabox' } });
    fireEvent.change(screen.getByLabelText(/card list/i), { target: { value: 'Name,Quantity\nLightning Bolt,1\n' } });
    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: 'Name,Quantity\nLightning Bolt,1\n', format: 'manabox' }
    ));
  });

  it('populates the card list textarea from an uploaded file', async () => {
    render(<MemoryRouter><NewBuyInTab /></MemoryRouter>);
    fireEvent.click(screen.getByRole('button', { name: /paste list/i }));
    fireEvent.change(screen.getByLabelText(/import format/i), { target: { value: 'tcgplayer' } });

    const csvContent = 'TCGplayer Id,Product Line,Set Name,Product Name,Title,Number,Rarity,Condition,TCG Market Price,TCG Direct Low,TCG Low w/ Shipping,TCG Low Price,Total Quantity,Pending Quantity,Photo URL,Add to Quantity,TCG Marketplace Price,My Store Reserve Quantity,My Store Price\n1,Magic,Test Set,Test Card,,1,Common,Near Mint,1,1,1,1,2,0,,0,1,,\n';
    const file = new File([csvContent], 'export.csv', { type: 'text/csv' });
    fireEvent.change(screen.getByLabelText(/upload csv file/i), { target: { files: [file] } });

    await waitFor(() => expect(screen.getByLabelText(/card list/i)).toHaveValue(csvContent));
  });
  • [ ] Step 2: Run the tests to verify they fail

Run: npm run test:client -- NewBuyInTab Expected: FAIL โ€” the updated body-shape assertion fails (no format key sent yet); the new format-selector and file-upload tests fail because those elements don't exist yet.

  • [ ] Step 3: Implement the format selector and file upload

In client/src/pages/buylist/NewBuyInTab.jsx, add a format state next to the existing mode state:

javascript
  const [rawText, setRawText] = useState('');
  const [mode, setMode] = useState('select'); // 'select' | 'paste'

becomes:

javascript
  const [rawText, setRawText] = useState('');
  const [mode, setMode] = useState('select'); // 'select' | 'paste'
  const [format, setFormat] = useState('plain'); // 'plain' | 'manabox' | 'tcgplayer'

Add a file-upload handler near updateCard (before submit):

javascript
  const handleFileUpload = (e) => {
    const file = e.target.files[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = () => setRawText(String(reader.result || ''));
    reader.readAsText(file);
  };

Change the submit function's body construction:

javascript
      const body = mode === 'paste'
        ? { customer, rawText }
        : {

to:

javascript
      const body = mode === 'paste'
        ? { customer, rawText, format }
        : {

Finally, in the paste-mode JSX block near the bottom of the component, change:

jsx
      ) : (
        <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>
      )}

to:

jsx
      ) : (
        <div className="space-y-2">
          <div className="flex gap-2 items-center">
            <label htmlFor="import-format" className="font-semibold text-sm">Format</label>
            <select
              id="import-format"
              aria-label="Import format"
              className="border-2 border-black rounded px-2 py-1 text-xs bg-card"
              value={format}
              onChange={(e) => setFormat(e.target.value)}
            >
              <option value="plain">Plain text</option>
              <option value="manabox">ManaBox CSV</option>
              <option value="tcgplayer">TCGplayer CSV</option>
            </select>
            {format !== 'plain' && (
              <label className="text-xs underline cursor-pointer">
                Upload file
                <input
                  type="file"
                  accept=".csv"
                  className="hidden"
                  aria-label="Upload CSV file"
                  onChange={handleFileUpload}
                />
              </label>
            )}
          </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={format === 'plain' ? '1 Ragavan, Nimble Pilferer [MH2] 138\n4 Orcish Bowmasters [LTR] 103' : 'Upload a file above, or paste the exported CSV content here'}
          />
        </div>
      )}
  • [ ] Step 4: Run the tests to verify they pass

Run: npm run test:client -- NewBuyInTab Expected: PASS โ€” all tests in both describe blocks green.

  • [ ] Step 5: Commit
bash
git add client/src/pages/buylist/NewBuyInTab.jsx client/src/pages/buylist/NewBuyInTab.test.jsx
git commit -m "Let merchants import a ManaBox or TCGplayer CSV export in New Buy-In"

Task 7: Quality bars, parity check, final verification โ€‹

Files: none (verification only).

  • [ ] Step 1: Full server suite + coverage

Run: npm test Expected: PASS, 0 failures.

Run: npm run test:coverage Expected: server/services/buylistQuoteService.js, server/schemas/buylistOrder.js, and server/routes/api.js all report โ‰ฅ70% on all four metrics.

  • [ ] Step 2: Full client suite

Run: npm run test:client Expected: PASS, 0 failures.

  • [ ] Step 3: Lint

Run: npm run lint Expected: 0 new warnings (compare against a pre-change baseline if any pre-existing warnings are already present in touched files).

  • [ ] Step 4: Build

Run: npm run build Expected: exits 0 (client touched in Task 6).

  • [ ] Step 5: Manual smoke test

With the dev server running (npm run dev), open New Buy-In, switch to "Paste List", select "ManaBox CSV" from the format dropdown, and paste:

Name,Set Code,Set Name,Collector Number,Foil,Rarity,Quantity,ManaBox ID,Scryfall ID,Purchase Price,Misprint,Altered,Condition,Language,Purchase Price Currency
Lightning Bolt,CLB,Alchemy Horizons: Baldur's Gate,187,false,common,2,mb-1,sf-1,0.25,false,false,Near Mint,en,USD

Confirm the generated offer shows a matched line for Lightning Bolt with quantity 2. Repeat with "TCGplayer CSV" selected and a row built from the header in Task 3's tests. This exercises the real Mongo-backed matchCatalogLine path end-to-end (mocks only cover the parsers themselves).

  • [ ] Step 6: Grep for stray || 'mtg' defaults (ยง5.5)

Run: git diff main --name-only | xargs grep -n "|| 'mtg'\|?? 'mtg'\|= 'mtg'" Expected: no matches in this diff's files.

  • [ ] Step 7: Final PR
bash
git push -u origin HEAD
gh pr create --title "Add ManaBox and TCGplayer CSV import connectors to Smart Buylist" --body "$(cat <<'EOF'
## Summary
- Adds two new buylist intake formats (ManaBox collection-export CSV, TCGplayer app collection-export CSV) alongside the existing free-text paste, reusing the same match/price/rate pipeline unchanged.
- Format grounding: both column layouts were verified against each vendor's own public documentation (cited in the plan's Global Constraints) rather than a live sample file, per direction from the user -- residual unknowns (ManaBox's exact Foil/Condition literal values) are explicitly not guessed; the connectors degrade safely to existing fallback behavior instead (drop Foil entirely, matching parseCardList's existing no-explicit-finish behavior; unrecognized condition strings fall back to 'nm', exactly as they already do today).
- Game parity: no plugin code touched. ManaBox is MTG-only by the product's own design (not a code limitation); TCGplayer's CSV spans product lines and works identically for mtg/pokemon/riftbound since matching stays plugin-driven via the existing matchCatalogLine.

## Test plan
- [ ] `npm test` and `npm run test:coverage` pass with all touched files โ‰ฅ70%
- [ ] `npm run test:client` passes
- [ ] `npm run lint` clean
- [ ] Manual smoke test: New Buy-In โ†’ Paste List โ†’ ManaBox CSV and TCGplayer CSV formats both produce a matched offer line

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

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

  • Spec coverage: "Independent track โ€” structured import formats" (spec) asks for ManaBox and TCGplayer app list import, extending Phase 2's parseCardList โ€” covered by Tasks 1-4 (parsers + dispatch). "Do not start without collecting an actual sample export" is the one requirement this plan cannot fully satisfy (no live sample was available); it's addressed by exhaustive citation of vendor documentation instead, with every un-citable literal explicitly called out and designed around rather than guessed (Global Constraints section), per the user's explicit direction to proceed via research.
  • Type/name consistency: ParsedLine shape (rawLine, quantity, name, setCode, collectorNumber, condition) is identical across parseCardList (existing), parseManaBoxCsv (Task 2), and parseTcgplayerCsv (Task 3) โ€” verified field-for-field against matchCatalogLine's actual consumption (parsedLine.setCode, .collectorNumber, .name) and priceAndRateLine's actual consumption (parsed.rawLine, .condition, .quantity), both read directly from server/services/buylistQuoteService.js during planning. generateQuote's new format parameter name matches the schema field name (format) added in Task 5 and the client state variable name (format) added in Task 6 โ€” no renaming across the chain.
  • Known adaptation points: none flagged โ€” every literal used in code (column names, condition words) is a direct citation, not a guess to verify later.
  • Grounding: every column name and condition-vocabulary literal is cited to a specific URL in the Global Constraints section; every value NOT found in public documentation is named explicitly there, with the safe-fallback design explained rather than an invented value substituted โ€” per CLAUDE.md ยง5.4 and this repo's repeated "Invented Vocabulary"/"Broken Join" failure history.
  • Backward compatibility: format is optional everywhere (Zod default 'plain', generateQuote default 'plain') โ€” every existing caller (route, any future caller) that doesn't pass it gets byte-identical behavior to before this plan.