/** * Amazon listings adapter (SP-API). Delivery follows Amazon's price-automation * guidance: per-SKU `patchListingsItem` (price + quantity in ONE call, op * `merge` on purchasable_offer), `ACCEPTED` = sent, confirmation later by the * outbox's spot-check / reconcile — never a read-after-write poll. Large * batches go out as one JSON_LISTINGS_FEED per order source, at most one every * LISTING_FEED_MIN_INTERVAL_MS (Amazon: ~20 min per feed type). * * Env: LISTING_PATCH_THRESHOLD (default 50 → above this a feed is used), * LISTING_FEED_MIN_INTERVAL_MS (default 20 min). */ import { amazonConnectionFromOrderSource, getMerchantListingsReport, getListingsItem, patchListingsItem, putListingsItem, deleteListingsItem, buildPurchasableOfferPatch, buildFulfillmentAvailabilityPatch, buildOfferOnlyAttributes, getCatalogItemProductType, createFeedDocument, uploadFeedDocument, createFeed, getFeed, downloadFeedResultDocument, buildJsonListingsFeed, parseJsonListingsFeedResult, getCompetitiveSummaryBatch, getItemOffers, getFeesEstimate, updateCachedListingPrice, updateCachedListingQuantity, MARKETPLACE_IDS, type AmazonOrderSourceConnection, type JsonListingsFeedMessage, type ListingsSubmissionResult } from '../../amazonSpApi' import { acquire, LISTINGS_WRITE_BUCKET, LISTINGS_READ_BUCKET } from '../rateLimiter' import type { AdapterCtx, ListingAdapter, ImportResult, PushItem, PushResult, PushOptions, ValidationResult, CompetitionResult, FeedPollResult } from './types' import type { ListingRow } from '../listingStore' const PATCH_THRESHOLD = Math.max(0, Number(process.env.LISTING_PATCH_THRESHOLD ?? 50)) const FEED_MIN_INTERVAL_MS = Math.max(60_000, Number(process.env.LISTING_FEED_MIN_INTERVAL_MS ?? 20 * 60 * 1000)) const PRICING_BUCKET = { ratePerSec: 0.034, burst: 1 } // getCompetitiveSummary: 0.033 req/s const lastFeedAt = new Map() // marketplaceId → ISO currency (fallback when the listing has no C_Currency_ID) const MARKETPLACE_CURRENCY: Record = Object.fromEntries(([ ['DE', 'EUR'], ['FR', 'EUR'], ['IT', 'EUR'], ['ES', 'EUR'], ['NL', 'EUR'], ['BE', 'EUR'], ['AT', 'EUR'], ['UK', 'GBP'], ['PL', 'PLN'], ['SE', 'SEK'] ] as [string, string][]).map(([c, cur]) => [MARKETPLACE_IDS[c] || c, cur])) const isTransient = (e: any): boolean => { const st = Number(e?.status || 0) return st === 429 || st === 500 || st === 502 || st === 503 || st === 504 || /timed out|ECONNRESET|fetch failed/i.test(String(e?.message || '')) } const errMsg = (e: any): string => String(e?.message || e).slice(0, 1900) const connect = (ctx: AdapterCtx): Promise => amazonConnectionFromOrderSource(ctx.orderSource) const currencyOf = (listing: ListingRow, marketplaceId: string) => listing.currency || MARKETPLACE_CURRENCY[marketplaceId] || 'EUR' const isFba = (listing: ListingRow) => String(listing.fulfillmentChannel || 'DEFAULT').toUpperCase() !== 'DEFAULT' // Resolve the Amazon productType for one listing (persisted afterwards by the // outbox via PushResult.productType). Listed SKU → its own summary; new offer → catalog. const resolveProductType = async (conn: AmazonOrderSourceConnection, item: PushItem): Promise => { if (item.listing.productType) return item.listing.productType await acquire(`amazon:read:${conn.sellerId}`, LISTINGS_READ_BUCKET) if (item.op === 'create') { const asin = item.listing.externalProductId if (!asin) throw Object.assign(new Error('ASIN missing — cannot create an Amazon offer without ExternalProductId'), { status: 400 }) const pt = await getCatalogItemProductType(conn.accessToken, asin, conn.marketplaceId) if (!pt) throw Object.assign(new Error(`No product type found in the Amazon catalog for ASIN ${asin}`), { status: 404 }) return pt } const current = await getListingsItem(conn.accessToken, conn.sellerId, item.listing.sellerSku, conn.marketplaceId) if (!current?.productType) throw Object.assign(new Error(`SKU "${item.listing.sellerSku}" is not listed in this marketplace`), { status: 404 }) return current.productType } // What goes into the PATCH for an update / delist / relist item. const buildPatches = (item: PushItem, marketplaceId: string): { patches: any[]; error?: string } => { const patches: any[] = [] const l = item.listing const wantsOffer = item.price != null || item.minPrice != null || item.maxPrice != null || item.sale != null if (wantsOffer) { patches.push(buildPurchasableOfferPatch({ marketplaceId, currency: currencyOf(l, marketplaceId), price: item.price ?? undefined, minPrice: item.minPrice ?? undefined, maxPrice: item.maxPrice ?? undefined, salePrice: item.sale?.price ?? undefined, saleFrom: item.sale?.from ?? undefined, saleTo: item.sale?.to ?? undefined })) } let qty: number | null | undefined = item.qty if (item.op === 'delist') qty = 0 if (item.op === 'relist') { qty = item.qty ?? l.qty ?? 0 // A listing Amazon shows as "Closed" carries purchasable_offer.end_at in the past; quantity alone // does not reopen it. Rewrite the offer (replace → no end_at) with the current price. const price = item.price ?? l.price ?? l.remotePrice if (price == null) return { patches, error: 'Relist needs a price (none stored for this listing)' } if (!wantsOffer) patches.length = 0 patches.unshift(buildPurchasableOfferPatch({ marketplaceId, currency: currencyOf(l, marketplaceId), op: 'replace', price, minPrice: item.minPrice ?? l.minPrice ?? undefined, maxPrice: item.maxPrice ?? l.maxPrice ?? undefined, salePrice: item.sale?.price ?? undefined, saleFrom: item.sale?.from ?? undefined, saleTo: item.sale?.to ?? undefined })) } if (qty != null) { if (isFba(l)) return { patches, error: `Quantity of an FBA listing (${l.fulfillmentChannel}) is managed by Amazon and cannot be changed here` } patches.push(buildFulfillmentAvailabilityPatch(qty)) } if (!patches.length) return { patches, error: 'Nothing to send for this listing (no price / quantity change)' } return { patches } } const resultOf = (item: PushItem, sub: ListingsSubmissionResult, mode: 'patch' | 'feed', productType: string | null): PushResult => { const warnings = sub.issues.filter(i => i.severity === 'WARNING' || i.severity === 'INFO').map(i => `${i.code}: ${i.message}`) if (sub.blocking) { const msg = sub.issues.filter(i => i.severity !== 'WARNING' && i.severity !== 'INFO').map(i => `${i.code}: ${i.message}`).join('; ') || `Amazon answered status ${sub.status || 'unknown'}` return { listingId: item.listing.id, changeIds: item.changes.map(c => c.id), status: 'error', deliveryMode: mode, error: msg, transient: false, productType, warnings } } return { listingId: item.listing.id, changeIds: item.changes.map(c => c.id), status: 'sent', deliveryMode: mode, submissionId: sub.submissionId, productType, warnings } } const pushOne = async (ctx: AdapterCtx, conn: AmazonOrderSourceConnection, item: PushItem, mode?: 'VALIDATION_PREVIEW'): Promise => { const l = item.listing const base = { listingId: l.id, changeIds: item.changes.map(c => c.id), deliveryMode: 'patch' as const } let productType: string | null = null try { if (item.op === 'delete') { await acquire(`amazon:write:${conn.sellerId}`, LISTINGS_WRITE_BUCKET) return resultOf(item, await deleteListingsItem(conn.accessToken, conn.sellerId, l.sellerSku, conn.marketplaceId), 'patch', l.productType) } productType = await resolveProductType(conn, item) await acquire(`amazon:write:${conn.sellerId}`, LISTINGS_WRITE_BUCKET) if (item.op === 'create') { const attributes = buildOfferOnlyAttributes({ marketplaceId: conn.marketplaceId, asin: l.externalProductId!, offer: { marketplaceId: conn.marketplaceId, currency: currencyOf(l, conn.marketplaceId), price: item.price ?? l.price, minPrice: item.minPrice ?? l.minPrice, maxPrice: item.maxPrice ?? l.maxPrice }, quantity: item.qty ?? l.qty ?? 0 }) const sub = await putListingsItem(conn.accessToken, conn.sellerId, l.sellerSku, conn.marketplaceId, productType, attributes, mode ? { mode } : {}) const res = resultOf(item, sub, 'patch', productType) if (res.status === 'sent' && !mode) { if (item.price ?? l.price) updateCachedListingPrice(ctx.orderSourceId, l.sellerSku, Number(item.price ?? l.price)); updateCachedListingQuantity(ctx.orderSourceId, l.sellerSku, Number(item.qty ?? l.qty ?? 0)) } return res } const built = buildPatches(item, conn.marketplaceId) if (built.error) return { ...base, status: 'error', error: built.error, transient: false, productType } const sub = await patchListingsItem(conn.accessToken, conn.sellerId, l.sellerSku, conn.marketplaceId, productType, built.patches, mode ? { mode } : {}) const res = resultOf(item, sub, 'patch', productType) if (res.status === 'sent' && !mode) { if (item.price != null) updateCachedListingPrice(ctx.orderSourceId, l.sellerSku, Number(item.price)) const q = item.op === 'delist' ? 0 : (item.op === 'relist' ? (item.qty ?? l.qty ?? 0) : item.qty) if (q != null) updateCachedListingQuantity(ctx.orderSourceId, l.sellerSku, Number(q)) } return res } catch (e: any) { return { ...base, status: 'error', error: errMsg(e), transient: isTransient(e), productType } } } const feedMessageFor = (item: PushItem, messageId: number, productType: string, marketplaceId: string): { msg?: JsonListingsFeedMessage; error?: string } => { const l = item.listing if (item.op === 'delete') return { msg: { messageId, sku: l.sellerSku, operationType: 'DELETE' } } if (item.op === 'create') { if (!l.externalProductId) return { error: 'ASIN missing — cannot create an Amazon offer without ExternalProductId' } return { msg: { messageId, sku: l.sellerSku, operationType: 'UPDATE', productType, requirements: 'LISTING_OFFER_ONLY', attributes: buildOfferOnlyAttributes({ marketplaceId, asin: l.externalProductId, offer: { marketplaceId, currency: currencyOf(l, marketplaceId), price: item.price ?? l.price, minPrice: item.minPrice ?? l.minPrice, maxPrice: item.maxPrice ?? l.maxPrice }, quantity: item.qty ?? l.qty ?? 0 }) } } } const built = buildPatches(item, marketplaceId) if (built.error) return { error: built.error } return { msg: { messageId, sku: l.sellerSku, operationType: 'PATCH', productType, patches: built.patches } } } const pushFeed = async (ctx: AdapterCtx, conn: AmazonOrderSourceConnection, items: PushItem[]): Promise => { const results: PushResult[] = [] const messages: JsonListingsFeedMessage[] = [] const pending: { item: PushItem; messageId: number; productType: string }[] = [] let messageId = 0 for (const item of items) { const base = { listingId: item.listing.id, changeIds: item.changes.map(c => c.id), deliveryMode: 'feed' as const } let productType: string try { productType = await resolveProductType(conn, item) } catch (e: any) { results.push({ ...base, status: 'error', error: errMsg(e), transient: isTransient(e) }); continue } const built = feedMessageFor(item, ++messageId, productType, conn.marketplaceId) if (!built.msg) { messageId--; results.push({ ...base, status: 'error', error: built.error, transient: false, productType }); continue } messages.push(built.msg) pending.push({ item, messageId, productType }) } if (!messages.length) return results try { const body = buildJsonListingsFeed(conn.sellerId, messages) const doc = await createFeedDocument(conn.accessToken) await uploadFeedDocument(doc.url, body) const feedId = await createFeed(conn.accessToken, 'JSON_LISTINGS_FEED', [conn.marketplaceId], doc.feedDocumentId) lastFeedAt.set(ctx.orderSourceId, Date.now()) ctx.log(`feed ${feedId} submitted with ${messages.length} messages`) for (const p of pending) { results.push({ listingId: p.item.listing.id, changeIds: p.item.changes.map(c => c.id), status: 'sent', deliveryMode: 'feed', feedId, messageId: p.messageId, productType: p.productType }) const l = p.item.listing if (p.item.price != null) updateCachedListingPrice(ctx.orderSourceId, l.sellerSku, Number(p.item.price)) const q = p.item.op === 'delist' ? 0 : (p.item.op === 'relist' ? (p.item.qty ?? l.qty ?? 0) : p.item.qty) if (q != null) updateCachedListingQuantity(ctx.orderSourceId, l.sellerSku, Number(q)) } } catch (e: any) { for (const p of pending) results.push({ listingId: p.item.listing.id, changeIds: p.item.changes.map(c => c.id), status: 'error', deliveryMode: 'feed', error: `Feed submission failed: ${errMsg(e)}`, transient: isTransient(e) || true, productType: p.productType }) } return results } export const amazonAdapter: ListingAdapter = { key: 'amazon', feedMinIntervalMs: FEED_MIN_INTERVAL_MS, async importRemote(ctx, opts = {}): Promise { const conn = await connect(ctx) const res = await getMerchantListingsReport(conn.accessToken, ctx.orderSourceId, conn.marketplaceId, { forceRefresh: !!opts.forceRefresh, maxWaitMs: opts.maxWaitMs ?? 20000 }) if (res.pending) return { pending: true, startedAt: res.startedAt } return { pending: false, generatedAt: res.generatedAt, fromCache: res.fromCache, rows: res.rows.map(r => ({ listingKey: r.sellerSku, sellerSku: r.sellerSku, externalProductId: r.asin || null, title: r.itemName, price: r.price, currency: MARKETPLACE_CURRENCY[conn.marketplaceId] || 'EUR', qty: r.quantity, fulfillmentChannel: r.fulfillmentChannel || 'DEFAULT', status: r.status, dateListed: r.openDate, meta: { marketplaceId: conn.marketplaceId } })) } }, async push(ctx, items, opts: PushOptions = {}): Promise { if (!items.length) return [] const conn = await connect(ctx) const feedAllowed = opts.allowFeed !== false && (Date.now() - (lastFeedAt.get(ctx.orderSourceId) || 0)) >= FEED_MIN_INTERVAL_MS const useFeed = feedAllowed && (opts.forceFeed || items.length > PATCH_THRESHOLD) if (useFeed) return pushFeed(ctx, conn, items) const out: PushResult[] = [] for (const item of items) out.push(await pushOne(ctx, conn, item)) return out }, async validate(ctx, items): Promise { const conn = await connect(ctx) const out: ValidationResult[] = [] for (const item of items) { const r = await pushOne(ctx, conn, item, 'VALIDATION_PREVIEW') out.push({ listingId: item.listing.id, ok: r.status === 'sent', issues: [...(r.error ? [r.error] : []), ...(r.warnings || [])] }) } return out }, async pollFeed(ctx, feedId, messageIds): Promise { const conn = await connect(ctx) const f = await getFeed(conn.accessToken, feedId) if (f.processingStatus === 'DONE') { if (!f.resultFeedDocumentId) return { done: true, fatal: 'Feed DONE without result document', status: f.processingStatus } const text = await downloadFeedResultDocument(conn.accessToken, f.resultFeedDocumentId) return { done: true, results: parseJsonListingsFeedResult(text, messageIds).results, status: f.processingStatus } } if (f.processingStatus === 'CANCELLED' || f.processingStatus === 'FATAL') return { done: true, fatal: `Feed ${f.processingStatus}`, status: f.processingStatus } return { done: false, status: f.processingStatus } }, async fetchCompetition(ctx, listings): Promise { const conn = await connect(ctx) const out = new Map() for (const l of listings) out.set(l.id, { listingId: l.id, buyBoxStatus: 'unknown', buyBoxPrice: null, lowestOtherPrice: null, competitorCount: null, feesEstimate: null, feesJson: null }) const withAsin = listings.filter(l => l.externalProductId) // competitive summary in batches of 20 (0.033 req/s); v0 getItemOffers as fallback when the role lacks it let useV0 = false for (let i = 0; i < withAsin.length && !useV0; i += 20) { const chunk = withAsin.slice(i, i + 20) try { await acquire(`amazon:pricing:${conn.sellerId}`, PRICING_BUCKET) const sums = await getCompetitiveSummaryBatch(conn.accessToken, conn.sellerId, chunk.map(l => l.externalProductId!), conn.marketplaceId) for (const l of chunk) { const s = sums.find(x => x.asin === l.externalProductId) if (!s) continue const r = out.get(l.id)! r.buyBoxPrice = s.featuredPrice; r.lowestOtherPrice = s.lowestOtherPrice; r.competitorCount = s.competitorCount r.buyBoxStatus = s.featuredIsMine == null ? 'unknown' : (s.featuredIsMine ? 'won' : 'lost') } } catch (e: any) { if (i === 0 && (Number(e?.status) === 403 || Number(e?.status) === 400)) { useV0 = true; ctx.log('competitiveSummary unavailable, falling back to getItemOffers', { error: errMsg(e) }) } else for (const l of chunk) out.get(l.id)!.error = errMsg(e) } } if (useV0) { for (const l of withAsin) { try { await acquire(`amazon:offers:${conn.sellerId}`, { ratePerSec: 0.5, burst: 1 }) const o = await getItemOffers(conn.accessToken, l.externalProductId!, conn.marketplaceId) const r = out.get(l.id)! if (o) { r.buyBoxPrice = o.buyBoxPrice; r.lowestOtherPrice = o.lowestOtherPrice; r.competitorCount = o.competitorCount; r.buyBoxStatus = o.buyBoxWinner == null ? 'unknown' : (o.buyBoxWinner ? 'won' : 'lost') } } catch (e: any) { out.get(l.id)!.error = errMsg(e) } } } // fees (1 req/s) at the desired price for (const l of listings) { const price = l.price ?? l.remotePrice if (!price || price <= 0) continue try { await acquire(`amazon:fees:${conn.sellerId}`, { ratePerSec: 0.9, burst: 1 }) const fees = await getFeesEstimate(conn.accessToken, l.sellerSku, conn.marketplaceId, price, isFba(l), currencyOf(l, conn.marketplaceId)) const r = out.get(l.id)! if (fees) { r.feesEstimate = fees.totalFees; r.feesJson = fees } } catch (e: any) { const r = out.get(l.id)!; r.error = r.error ? `${r.error}; fees: ${errMsg(e)}` : `fees: ${errMsg(e)}` } } return [...out.values()] }, validateCreate(_ctx, product) { if (!product.asin) return 'ASIN is required to list a product on Amazon' if (!product.value) return 'Product value (SKU) is required' return null } }