diff --git a/server/src/services/etsyApi.ts b/server/src/services/etsyApi.ts index 3bf95aa..76aa6d8 100644 --- a/server/src/services/etsyApi.ts +++ b/server/src/services/etsyApi.ts @@ -151,24 +151,31 @@ const sizeToken = (text: string): string => { return ''; }; +// Build the variant suffix for a transaction, e.g. "Size: Large" +export const transactionVariantString = (transaction: any): string => + (transaction.variations || []) + .map((v: any) => `${v.formatted_name}: ${v.formatted_value}`) + .join(', '); + // Match an Etsy transaction to a catalog product: -// 1. exact title/alias match (including "title variations" form) +// 1. exact title/alias match — "title variant" first so a size-specific +// product beats a generic one when listings have size variants // 2. listing id match, disambiguated by size when several products share it const matchProduct = ( products: IProduct[], transaction: any ): IProduct | undefined => { - const variantStr = (transaction.variations || []) - .map((v: any) => `${v.formatted_name}: ${v.formatted_value}`) - .join(', '); + const variantStr = transactionVariantString(transaction); const candidatesByTitle = [ - transaction.title || '', variantStr ? `${transaction.title} ${variantStr}` : '', + transaction.title || '', ].filter(Boolean).map(normalizeTitle); - for (const product of products) { - const names = [product.title, ...(product.aliases || [])].map(normalizeTitle); - if (candidatesByTitle.some(c => names.includes(c))) return product; + for (const candidate of candidatesByTitle) { + const product = products.find(p => + [p.title, ...(p.aliases || [])].map(normalizeTitle).includes(candidate) + ); + if (product) return product; } if (transaction.listing_id) { @@ -243,11 +250,15 @@ const upsertOrderFromReceipt = async ( // Snapshot catalog costs onto items at sync time (same model as PDF import) let allMatched = true; const items = (receipt.transactions || []).map((transaction: any) => { + const variantStr = transactionVariantString(transaction); const product = matchProduct(products, transaction); if (!product) { allMatched = false; - if (!result.unmatchedItems.includes(transaction.title)) { - result.unmatchedItems.push(transaction.title); + // Report with the variant suffix so each size resolves to its own + // product; this exact string is what the matcher checks on re-sync + const unmatchedKey = variantStr ? `${transaction.title} ${variantStr}` : transaction.title; + if (!result.unmatchedItems.includes(unmatchedKey)) { + result.unmatchedItems.push(unmatchedKey); } } else if (!product.etsyListingId && transaction.listing_id) { // Learn the listing id for future deterministic matching @@ -255,10 +266,6 @@ const upsertOrderFromReceipt = async ( product.save().catch(() => {}); } - const variantStr = (transaction.variations || []) - .map((v: any) => `${v.formatted_name}: ${v.formatted_value}`) - .join(', '); - return { title: transaction.title, quantity: transaction.quantity || 1,