import refreshTokenHelper from "../../../utils/refreshTokenHelper" import forceLogoutHelper from "../../../utils/forceLogoutHelper" import errorHandlingHelper from "../../../utils/errorHandlingHelper" import { resolveOrderSourceAmazonConnection, getListingsItem, patchListingsItemPrice, updateCachedListingPrice, withPricePushQueue } from "../../../utils/amazonSpApi" const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) // SIS — push one corrected price to Amazon. Body: { orderSourceId, sku, // newPrice }. No persisted audit trail (explicitly out of scope) — every // push is console.log'd (greppable in the PM2 out-log, same traceability // convention as commissionTable.ts) and the confirmed new state is returned // so the frontend can update the row + its own in-session "recent changes" // list. // // IMPORTANT (verified against real production data — the read-after-write // used to show the stale price even on a genuinely successful push): the // Listings Items API PATCH is NOT synchronous. It returns `status: // "ACCEPTED"` — a submission that Amazon processes asynchronously, same // shape as the Feeds API, just normally fast. An immediate GET right after // can still show the old price. So: poll briefly for the read to catch up, // but once Amazon has ACCEPTED the submission with no blocking issues, // treat the push as successful regardless of whether the poll converged in // time, and report the requested price rather than a stale read. const handleFunc = async (event: any, authToken: any = null) => { const token = authToken ?? await getTokenHelper(event) const body = await readBody(event) const orderSourceId = Number(body?.orderSourceId) const sku = String(body?.sku || '').trim() const newPrice = Number(body?.newPrice) if (!orderSourceId) return { status: 400, message: 'orderSourceId is required' } if (!sku) return { status: 400, message: 'sku is required' } if (!Number.isFinite(newPrice) || newPrice <= 0) return { status: 400, message: 'newPrice must be a positive number' } const { sellerId, marketplaceId, accessToken } = await resolveOrderSourceAmazonConnection(event, token, orderSourceId) let userLabel = 'unknown' try { const raw = getCookie(event, 'logship_user') const user = raw ? JSON.parse(raw) : null userLabel = user?.Name || user?.email || getCookie(event, 'logship_user_id') || 'unknown' } catch { userLabel = getCookie(event, 'logship_user_id') || 'unknown' } // Amazon's Listings Items operations are tightly rate-limited (~1 req/sec, // no bulk equivalent — see throttledBatch's comment in amazonSpApi.ts) and // the frontend's per-field push is deliberately non-blocking (the user can // tab to the next row before this resolves), so several pushes to the same // seller can otherwise fire concurrently. Serialize + pace them here // instead of changing that UX. return withPricePushQueue(orderSourceId, async () => { const before = await getListingsItem(accessToken, sellerId, sku, marketplaceId).catch(() => null) let patchResult: any = null try { // Pass `before` through so patchListingsItemPrice can skip its own // redundant GET of the same SKU just to read productType. patchResult = await patchListingsItemPrice(accessToken, sellerId, sku, marketplaceId, newPrice, 'EUR', before) } catch (e: any) { console.error('[sis] price push FAILED', { user: userLabel, orderSourceId, sku, oldPrice: before?.price ?? null, newPrice, error: e?.message || String(e) }) throw e } const blockingIssues = (patchResult?.issues || []).filter((i: any) => (i?.severity || '').toUpperCase() !== 'WARNING') if (patchResult?.status !== 'ACCEPTED' || blockingIssues.length > 0) { const msg = blockingIssues.map((i: any) => i.message).join('; ') || `Amazon rejected the submission (status: ${patchResult?.status})` console.error('[sis] price push REJECTED', { user: userLabel, orderSourceId, sku, newPrice, issues: patchResult?.issues }) return { status: 502, message: `Amazon rejected the price update: ${msg}` } } // Poll briefly for the read to catch up (Amazon applies ACCEPTED submissions // asynchronously) — not required for success, just for an accurate confirmed // read when possible instead of always falling back to the requested price. let after: any = null for (let attempt = 0; attempt < 4; attempt++) { await sleep(1500) after = await getListingsItem(accessToken, sellerId, sku, marketplaceId).catch(() => null) if (after?.price != null && Math.abs(after.price - newPrice) < 0.005) break } const confirmedPrice = (after?.price != null && Math.abs(after.price - newPrice) < 0.005) ? after.price : newPrice const pending = confirmedPrice === newPrice && after?.price !== newPrice // Keep the cached listings report (up to 30 min old) from serving the // stale pre-push price on the next reload/refresh — trust the ACCEPTED // submission even if the poll above didn't converge in time. updateCachedListingPrice(orderSourceId, sku, confirmedPrice) console.log('[sis] price push', { user: userLabel, orderSourceId, sku, oldPrice: before?.price ?? null, requestedPrice: newPrice, confirmedPrice, pending }) return { status: 200, sku, oldPrice: before?.price ?? null, requestedPrice: newPrice, pending, confirmed: { ...(after || {}), price: confirmedPrice } } }) } export default defineEventHandler(async (event) => { let data: any = {} try { data = await handleFunc(event) } catch (err: any) { // An Amazon-side failure (429, rejected submission, timeout, ...) is not // an auth problem — retrying would silently re-run the whole push (up to // ~7 Amazon calls) a second time, doubling load exactly when Amazon is // already unhappy. Fail straight to the client instead. if (err?.isAmazonError) { data = errorHandlingHelper(err?.data ?? err, err?.data ?? err) return data } try { let authToken: any = await refreshTokenHelper(event) data = await handleFunc(event, authToken) } catch (error: any) { data = errorHandlingHelper(err?.data ?? err, error?.data ?? error) forceLogoutHelper(event, data) } } return data })