/** * Shopware 6 listings adapter — Admin API with the integration credentials stored on the * order source (same mechanism as the Laravel middleware's ShopwareTokenRepository): * marketplace_url = "https://shop.example/api", marketplace_key / marketplace_secret = * integration access key / secret → POST {url}/oauth/token (client_credentials, ~10 min). * One SIS row per sellable product (simple products and variants; parents with children are * skipped — they are not purchasable themselves): * ListingKey = product id (uuid) ExternalProductId = parent id (variants) or own id * SellerSKU = productNumber MetaJson { variantTitle, imageUrl, adminUrl, storefrontUrl, taxId, currencyId } * import POST /search/product (paged, inherited name / price / cover resolved from the parent) * price PATCH /product/{id} { price: [...] } — only the system-default currency entry is replaced * (gross = listing price, net from the product's tax rate, other currencies untouched) * qty PATCH /product/{id} { stock } delist / relist = stock 0 / back * create POST /product (name, productNumber, stock, price, taxId, visibilities on every storefront channel) * Writes are synchronous → results are `confirmed` immediately. No competition data. */ import type { AdapterCtx, ListingAdapter, ImportResult, PushItem, PushResult, CompetitionResult, RemoteListing } from './types' interface SwConn { apiUrl: string; baseUrl: string; accessToken: string; currencyId: string; currencyIso: string } const tokenCache = new Map() const TOKEN_TTL_MS = 9 * 60 * 1000 const importCache = new Map() const IMPORT_CACHE_MS = 5 * 60 * 1000 const taxRateCache = new Map() // `${orderSourceId}:${taxId}` → rate % const STOREFRONT_TYPE_ID = '8a243080f92e4c719546314b577cf82b' const errMsg = (e: any): string => { const d = e?.data?.errors?.[0] return String(d ? `${d.title || ''} ${d.detail || ''}`.trim() : (e?.message || e)).slice(0, 1900) } const isTransient = (e: any): boolean => { const st = Number(e?.status || e?.statusCode || 0) return st === 429 || st >= 500 || /timed out|ECONNRESET|fetch failed/i.test(String(e?.message || '')) } const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)) /** Admin API base ("…/api") and the shop base ("…") from the order source. */ export const shopwareUrls = (orderSource: any): { apiUrl: string; baseUrl: string } => { let url = String(orderSource?.marketplace_url || orderSource?.Marketplace_URL || '').trim().replace(/\/+$/, '') if (url && !/^https?:\/\//i.test(url)) url = `https://${url}` const apiUrl = /\/api$/i.test(url) ? url : `${url}/api` return { apiUrl, baseUrl: apiUrl.replace(/\/api$/i, '') } } const swFetch = async (conn: SwConn, method: 'GET' | 'POST' | 'PATCH' | 'DELETE', path: string, body?: any, attempt = 0): Promise => { try { return await $fetch(`${conn.apiUrl}${path}`, { method, body, headers: { Authorization: `Bearer ${conn.accessToken}`, Accept: 'application/json', 'Content-Type': 'application/json' }, timeout: 60_000, retry: 0 } as any) } catch (e: any) { if (attempt < 3 && isTransient(e)) { await sleep(1000 * (attempt + 1)); return swFetch(conn, method, path, body, attempt + 1) } throw e } } const connect = async (ctx: AdapterCtx): Promise => { const c = tokenCache.get(ctx.orderSourceId) if (c && Date.now() - c.at < TOKEN_TTL_MS) return c.conn const os = ctx.orderSource const { apiUrl, baseUrl } = shopwareUrls(os) const key = os?.marketplace_key, secret = os?.marketplace_secret if (!apiUrl || !key || !secret) throw Object.assign(new Error('Shopware order source needs marketplace_url (…/api), marketplace_key and marketplace_secret (Admin API integration)'), { status: 400 }) const tok: any = await $fetch(`${apiUrl}/oauth/token`, { method: 'POST', body: { grant_type: 'client_credentials', client_id: key, client_secret: secret }, timeout: 30_000, retry: 0 } as any) if (!tok?.access_token) throw new Error('Shopware OAuth answered without access_token') const conn: SwConn = { apiUrl, baseUrl, accessToken: tok.access_token, currencyId: '', currencyIso: 'EUR' } // isSystemDefault is a runtime field (not filterable) → read the currencies and pick the default here const cur: any = await swFetch(conn, 'POST', '/search/currency', { limit: 50, includes: { currency: ['id', 'isoCode', 'isSystemDefault', 'factor'] } }) const currencies: any[] = cur?.data || [] const def = currencies.find(c => c?.isSystemDefault === true) || currencies.find(c => Number(c?.factor) === 1) || currencies[0] conn.currencyId = def?.id || '' conn.currencyIso = def?.isoCode || 'EUR' if (!conn.currencyId) throw new Error('Shopware: system default currency not found') tokenCache.set(ctx.orderSourceId, { at: Date.now(), conn }) return conn } const taxRate = async (ctx: AdapterCtx, conn: SwConn, taxId: string | null): Promise => { if (!taxId) return 19 const k = `${ctx.orderSourceId}:${taxId}` if (taxRateCache.has(k)) return taxRateCache.get(k)! const res: any = await swFetch(conn, 'GET', `/tax/${taxId}`) const rate = Number(res?.data?.taxRate ?? res?.data?.attributes?.taxRate ?? 19) taxRateCache.set(k, rate) return rate } const round2 = (n: number) => Math.round(n * 100) / 100 const priceOf = (p: any, currencyId: string): number | null => { const arr: any[] = Array.isArray(p?.price) ? p.price : [] const hit = arr.find(x => x?.currencyId === currencyId) || arr[0] return hit?.gross != null ? Number(hit.gross) : null } const variantTitleOf = (p: any): string => (p?.options || []).map((o: any) => o?.translated?.name || o?.name).filter(Boolean).join(' / ') const PRODUCT_FIELDS = ['id', 'productNumber', 'name', 'translated', 'parentId', 'childCount', 'stock', 'availableStock', 'active', 'price', 'taxId', 'createdAt', 'releaseDate', 'cover', 'options'] export const shopwareAdapter: ListingAdapter = { key: 'shopware', async importRemote(ctx, opts = {}): Promise { const cached = importCache.get(ctx.orderSourceId) if (!opts.forceRefresh && cached && Date.now() - cached.at < IMPORT_CACHE_MS) return { pending: false, rows: cached.rows, generatedAt: cached.at, fromCache: true } const conn = await connect(ctx) const products: any[] = [] for (let page = 1; page <= 400; page++) { const res: any = await swFetch(conn, 'POST', '/search/product', { page, limit: 100, 'total-count-mode': 0, sort: [{ field: 'productNumber', order: 'ASC' }], includes: { product: PRODUCT_FIELDS, media: ['url'], product_media: ['media'], property_group_option: ['name', 'translated'] }, associations: { cover: { associations: { media: {} } }, options: {} } }) const data: any[] = res?.data || [] products.push(...data) if (data.length < 100) break } // inherited fields of variants come from the parent (name, price, cover, tax) const byId = new Map(products.map(p => [p.id, p])) const missingParents = [...new Set(products.map(p => p.parentId).filter((id: any) => id && !byId.has(id)))] for (let i = 0; i < missingParents.length; i += 100) { const res: any = await swFetch(conn, 'POST', '/search/product', { limit: 100, filter: [{ type: 'equalsAny', field: 'id', value: missingParents.slice(i, i + 100).join('|') }], includes: { product: PRODUCT_FIELDS, media: ['url'], product_media: ['media'] }, associations: { cover: { associations: { media: {} } } } }) for (const p of (res?.data || [])) byId.set(p.id, p) } const rows: RemoteListing[] = [] for (const p of products) { if (Number(p.childCount) > 0) continue // parent of variants → not sellable itself const parent = p.parentId ? byId.get(p.parentId) : null const name = p?.translated?.name || p?.name || parent?.translated?.name || parent?.name || p.productNumber const variantTitle = variantTitleOf(p) const price = priceOf(p, conn.currencyId) ?? (parent ? priceOf(parent, conn.currencyId) : null) const imageUrl = p?.cover?.media?.url || parent?.cover?.media?.url || null const active = p.active != null ? !!p.active : (parent ? !!parent.active : true) rows.push({ listingKey: p.id, sellerSku: String(p.productNumber || '').trim(), externalProductId: p.parentId || p.id, externalInventoryId: null, title: variantTitle && !String(name).includes(variantTitle) ? `${name} – ${variantTitle}` : String(name), price, currency: conn.currencyIso, qty: p.stock != null ? Number(p.stock) : null, fulfillmentChannel: 'DEFAULT', status: active ? 'Active' : 'Inactive', dateListed: p.releaseDate || p.createdAt || null, meta: { variantTitle, imageUrl, taxId: p.taxId || parent?.taxId || null, currencyId: conn.currencyId, availableStock: p.availableStock ?? null, adminUrl: `${conn.baseUrl}/admin#/sw/product/detail/${p.id}`, storefrontUrl: `${conn.baseUrl}/detail/${p.id}` } }) } ctx.log(`shopware import: ${rows.length} sellable product(s) of ${products.length}`) importCache.set(ctx.orderSourceId, { at: Date.now(), rows }) return { pending: false, rows, generatedAt: Date.now(), fromCache: false } }, async push(ctx, items): Promise { if (!items.length) return [] const conn = await connect(ctx) const out: PushResult[] = [] for (const item of items) { const l = item.listing const base = { listingId: l.id, changeIds: item.changes.map(c => c.id), deliveryMode: 'patch' as const } try { if (item.op === 'delete') { out.push({ ...base, status: 'error', error: 'Deleting a Shopware product from here is not supported — remove it in the Shopware admin' }); continue } if (item.op === 'create') { out.push(await createProduct(ctx, conn, item)); continue } if (!/^[0-9a-f]{32}$/i.test(String(l.listingKey))) { out.push({ ...base, status: 'error', error: 'Listing has no Shopware product id (ListingKey) — re-import it' }); continue } const warnings: string[] = [] const patch: any = {} if (item.price != null && item.op !== 'delist') { // keep the other currency entries: read the current price array, replace the default-currency entry const cur: any = await swFetch(conn, 'GET', `/product/${l.listingKey}?includes[product]=price,taxId`) const current: any[] = Array.isArray(cur?.data?.price) ? cur.data.price : (Array.isArray(cur?.data?.attributes?.price) ? cur.data.attributes.price : []) const rate = await taxRate(ctx, conn, cur?.data?.taxId || cur?.data?.attributes?.taxId || l.meta?.taxId || null) const gross = round2(Number(item.price)) const entry = { currencyId: conn.currencyId, gross, net: round2(gross / (1 + rate / 100)), linked: true } patch.price = [entry, ...current.filter(x => x?.currencyId !== conn.currencyId)] } if (item.minPrice != null || item.maxPrice != null) warnings.push('min/max price are not supported on Shopware (ignored)') let qty: number | null | undefined = item.qty if (item.op === 'delist') qty = 0 if (item.op === 'relist') qty = item.qty ?? l.qty ?? 0 if (qty != null) patch.stock = Math.max(0, Math.trunc(Number(qty) || 0)) if (!Object.keys(patch).length) { out.push({ ...base, status: 'error', error: 'Nothing to send for this listing (no price / quantity change)' }); continue } await swFetch(conn, 'PATCH', `/product/${l.listingKey}`, patch) out.push({ ...base, status: 'sent', confirmed: true, submissionId: null, warnings }) } catch (e: any) { out.push({ ...base, status: 'error', error: errMsg(e), transient: isTransient(e) }) } } return out }, async fetchCompetition(_ctx, listings): Promise { return listings.map(l => ({ listingId: l.id, buyBoxStatus: 'unknown', buyBoxPrice: null, lowestOtherPrice: null, competitorCount: null, feesEstimate: null, feesJson: null, error: 'not available on Shopware' })) }, validateCreate(_ctx, product) { if (!String(product.value || '').trim()) return 'Product has no product number (Value) — Shopware needs it as productNumber' return null } } /** "List products": a new simple product (productNumber = ERP Value), visible on every active storefront sales channel. */ const createProduct = async (ctx: AdapterCtx, conn: SwConn, item: PushItem): Promise => { const l = item.listing const base = { listingId: l.id, changeIds: item.changes.map(c => c.id), deliveryMode: 'patch' as const } const warnings: string[] = [] const price = item.price ?? l.price if (price == null) return { ...base, status: 'error', error: 'price missing' } // tax: the lowest-positioned (= default) tax entry const taxes: any = await swFetch(conn, 'POST', '/search/tax', { limit: 1, sort: [{ field: 'position', order: 'ASC' }], includes: { tax: ['id', 'taxRate', 'name'] } }) const tax = taxes?.data?.[0] if (!tax?.id) return { ...base, status: 'error', error: 'Shopware: no tax entry found' } const channels: any = await swFetch(conn, 'POST', '/search/sales-channel', { limit: 50, filter: [{ type: 'equals', field: 'active', value: true }, { type: 'equals', field: 'typeId', value: STOREFRONT_TYPE_ID }], includes: { sales_channel: ['id', 'name'] } }) const visibilities = (channels?.data || []).map((c: any) => ({ salesChannelId: c.id, visibility: 30 })) if (!visibilities.length) warnings.push('no active storefront sales channel — product created without visibility') const gross = round2(Number(price)) const id = (globalThis.crypto?.randomUUID?.() || `${Date.now()}${Math.random()}`).replace(/-/g, '').slice(0, 32).padEnd(32, '0') const body: any = { id, name: l.name, productNumber: l.sellerSku, stock: Math.max(0, Math.trunc(Number(item.qty ?? l.qty ?? 0))), active: true, taxId: tax.id, price: [{ currencyId: conn.currencyId, gross, net: round2(gross / (1 + Number(tax.taxRate || 19) / 100)), linked: true }], visibilities } await swFetch(conn, 'POST', '/product', body) ctx.log(`created Shopware product ${id} for ${l.sellerSku}`) return { ...base, status: 'sent', confirmed: true, submissionId: id, warnings, remote: { listingKey: id, externalProductId: id, externalInventoryId: null, meta: { ...(l.meta || {}), taxId: tax.id, currencyId: conn.currencyId, adminUrl: `${conn.baseUrl}/admin#/sw/product/detail/${id}`, storefrontUrl: `${conn.baseUrl}/detail/${id}` } } } }