import crypto from 'crypto'; import config from '../../config.js'; import log4js from 'log4js'; const logger = log4js.getLogger('TikTok Shop'); logger.level = config.server.logLevel; const BASE_URL = 'https://open-api.tiktokglobalshop.com'; const AUTH_BASE_URL = 'https://auth.tiktok-shops.com'; const API_VERSION = '202309'; const AUTHORIZED_SHOPS_PATH = `/authorization/${API_VERSION}/shops`; function getTokenExpiryDate(unixTimestampSeconds) { return new Date(Number(unixTimestampSeconds || 0) * 1000); } function isAccessTokenExpired(marketplace) { const { accessToken, accessTokenExpiresAt } = marketplace.config || {}; if (!accessToken || !accessTokenExpiresAt) { return true; } return new Date(accessTokenExpiresAt).getTime() <= Date.now(); } function shouldIncludeShopCipher(path, method = 'GET') { if (/^\/product\/(\d{6})\/(compliance|global_products|files\/upload|images\/upload)/.test(path)) { return false; } if (method === 'POST' && /^\/product\/(\d{6})\/brands/.test(path)) { return false; } if (/^\/(authorization|seller)\/(\d{6})\//.test(path)) { return false; } return true; } function generateSignature({ path, method, params, body, appSecret }) { const paramsToBeSigned = { ...params }; delete paramsToBeSigned.sign; delete paramsToBeSigned.access_token; delete paramsToBeSigned['x-tts-access-token']; const sortedKeys = Object.keys(paramsToBeSigned).sort(); let payload = path; for (const key of sortedKeys) { const value = paramsToBeSigned[key]; if (!Array.isArray(value)) { payload += `${key}${value}`; } } if (method !== 'GET' && body && typeof body === 'object') { payload += JSON.stringify(body); } payload = `${appSecret}${payload}${appSecret}`; return crypto.createHmac('sha256', appSecret).update(payload).digest('hex'); } async function requestAuth(path, params) { const url = new URL(path, AUTH_BASE_URL); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null && value !== '') { url.searchParams.set(key, value); } } const response = await fetch(url.toString(), { method: 'GET' }); const data = await response.json(); if (!response.ok || data.code !== 0) { const message = data.message || response.statusText; logger.error(`TikTok Shop auth error: ${message}`, { path, code: data.code }); throw new Error(`TikTok Shop auth error: ${message} (code: ${data.code ?? response.status})`); } return data.data; } async function makeRequest({ marketplace, method = 'GET', path, params = {}, body = null }) { const { appKey, appSecret, accessToken, shopCipher } = marketplace.config || {}; if (!appKey || !appSecret || !accessToken) { throw new Error( 'TikTok Shop marketplace is missing required config (appKey, appSecret, accessToken)' ); } const queryParams = { ...params, app_key: appKey, timestamp: Math.floor(Date.now() / 1000), }; if (shopCipher && shouldIncludeShopCipher(path, method)) { queryParams.shop_cipher = shopCipher; } const sign = generateSignature({ path, method, params: queryParams, body, appSecret, }); queryParams.sign = sign; const queryString = Object.entries(queryParams) .filter(([, value]) => value !== undefined && value !== null && value !== '') .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join('&'); const url = `${BASE_URL}${path}?${queryString}`; const fetchOptions = { method, headers: { 'Content-Type': 'application/json', 'x-tts-access-token': accessToken, }, }; if (body && method !== 'GET') { fetchOptions.body = JSON.stringify(body); } logger.debug(`TikTok Shop API ${method} ${path}`); const response = await fetch(url, fetchOptions); const data = await response.json(); if (data.code !== 0) { logger.error(`TikTok Shop API error: ${data.message}`, { code: data.code, path }); throw new Error(`TikTok Shop API error: ${data.message} (code: ${data.code})`); } return data.data; } async function fetchAuthorizedShops(marketplace, accessToken) { const authMarketplace = { ...marketplace, config: { ...(marketplace.config || {}), accessToken, shopCipher: undefined, }, }; const data = await makeRequest({ marketplace: authMarketplace, method: 'GET', path: AUTHORIZED_SHOPS_PATH, }); if (Array.isArray(data?.shops)) { return data.shops; } if (Array.isArray(data?.shop_list)) { return data.shop_list; } if (Array.isArray(data)) { return data; } return []; } function pickAuthorizedShop(marketplace, shops) { if (!shops.length) { return null; } const existingShopCipher = marketplace.config?.shopCipher; const existingShopId = marketplace.config?.shopId; const existingShopCode = marketplace.config?.shopCode; return ( shops.find((shop) => shop.cipher === existingShopCipher) || shops.find((shop) => shop.id === existingShopId) || shops.find((shop) => shop.code === existingShopCode) || shops[0] ); } function getRequiredAuthConfig(marketplace) { const { appKey, appSecret } = marketplace.config || {}; if (!appKey || !appSecret) { throw new Error('TikTok Shop marketplace is missing required config (appKey, appSecret)'); } return { appKey, appSecret }; } function mapAuthConfigUpdates(marketplace, tokenData, shop) { return { accessToken: tokenData.access_token, accessTokenExpiresAt: getTokenExpiryDate(tokenData.access_token_expire_in).toISOString(), refreshToken: tokenData.refresh_token || marketplace.config.refreshToken, refreshTokenExpiresAt: tokenData.refresh_token_expire_in ? getTokenExpiryDate(tokenData.refresh_token_expire_in).toISOString() : marketplace.config.refreshTokenExpiresAt, openId: tokenData.open_id || marketplace.config.openId, grantedScopes: tokenData.granted_scopes || marketplace.config.grantedScopes || [], sellerName: tokenData.seller_name || shop?.name || marketplace.config.sellerName, sellerBaseRegion: tokenData.seller_base_region || shop?.region || marketplace.config.sellerBaseRegion, shopCipher: shop?.cipher || marketplace.config.shopCipher, shopId: shop?.id || marketplace.config.shopId, shopCode: shop?.code || marketplace.config.shopCode, shopName: shop?.name || marketplace.config.shopName, shopRegion: shop?.region || marketplace.config.shopRegion, lastTokenRefreshAt: new Date().toISOString(), }; } export function createAuthorizationUrl(marketplace, { state } = {}) { const { appKey } = getRequiredAuthConfig(marketplace); const url = new URL('/oauth/authorize', AUTH_BASE_URL); url.searchParams.set('app_key', appKey); if (state) { url.searchParams.set('state', state); } if (marketplace.config.redirectUri) { url.searchParams.set('redirect_uri', marketplace.config.redirectUri); } return url.toString(); } export async function exchangeAuthorizationCode(marketplace, { code }) { const { appKey, appSecret } = getRequiredAuthConfig(marketplace); if (!code) { throw new Error('Missing TikTok Shop authorization code'); } const tokenData = await requestAuth('/api/v2/token/get', { app_key: appKey, app_secret: appSecret, auth_code: code, grant_type: 'authorized_code', }); const shops = await fetchAuthorizedShops(marketplace, tokenData.access_token); const selectedShop = pickAuthorizedShop(marketplace, shops); return { configUpdates: mapAuthConfigUpdates(marketplace, tokenData, selectedShop), marketplaceUpdates: { connected: true, connectedAt: new Date(), }, data: { shopCount: shops.length, selectedShop, }, }; } export async function refreshAuth(marketplace) { const { appKey, appSecret } = getRequiredAuthConfig(marketplace); const { refreshToken } = marketplace.config || {}; if (!refreshToken) { throw new Error('TikTok Shop marketplace is missing required config (refreshToken)'); } const tokenData = await requestAuth('/api/v2/token/refresh', { app_key: appKey, app_secret: appSecret, refresh_token: refreshToken, grant_type: 'refresh_token', }); let selectedShop = null; if (!marketplace.config?.shopCipher) { const shops = await fetchAuthorizedShops(marketplace, tokenData.access_token); selectedShop = pickAuthorizedShop(marketplace, shops); } return { configUpdates: mapAuthConfigUpdates(marketplace, tokenData, selectedShop), data: { selectedShop, }, }; } export async function ensureAuthenticatedMarketplace(marketplace) { if (!isAccessTokenExpired(marketplace)) { return { marketplace }; } const authResult = await refreshAuth(marketplace); return { marketplace: { ...marketplace, config: { ...(marketplace.config || {}), ...authResult.configUpdates, }, }, configUpdates: authResult.configUpdates, }; } async function fetchAllProducts(marketplace) { const products = []; let pageToken = ''; do { const body = { page_size: 100, }; if (pageToken) { body.page_token = pageToken; } const data = await makeRequest({ marketplace, method: 'POST', path: `/product/${API_VERSION}/products/search`, body, }); if (data.products?.length) { products.push(...data.products); } pageToken = data.next_page_token || ''; } while (pageToken); return products; } async function fetchProductDetail(marketplace, productId) { return makeRequest({ marketplace, method: 'GET', path: `/product/${API_VERSION}/products/${productId}`, params: { return_under_review_version: false }, }); } async function fetchAllOrders(marketplace, { startTime, endTime } = {}) { const orders = []; let pageToken = ''; const now = Math.floor(Date.now() / 1000); do { const body = { page_size: 50, sort_field: 'CREATE_TIME', sort_order: 'DESC', }; if (startTime || endTime) { body.create_time_ge = startTime || now - 86400 * 30; body.create_time_lt = endTime || now; } if (pageToken) { body.page_token = pageToken; } const data = await makeRequest({ marketplace, method: 'POST', path: `/order/${API_VERSION}/orders/search`, body, }); if (data.orders?.length) { orders.push(...data.orders); } pageToken = data.next_page_token || ''; } while (pageToken); return orders; } async function fetchOrderDetail(marketplace, orderId) { return makeRequest({ marketplace, method: 'GET', path: `/order/${API_VERSION}/orders/${orderId}`, }); } function mapListingToProduct(listing) { const product = { title: listing.title || '', is_cod_allowed: false, }; if (listing.description) { product.description = listing.description; } if (listing.categoryId) { product.category_id = listing.categoryId; } if (listing.imageUrls?.length) { product.main_images = listing.imageUrls.map((url) => ({ uri: url })); } const sku = { inventory: [{ quantity: listing.inventory ?? 0 }], }; if (listing.price != null) { sku.price = { amount: String(listing.price), currency: listing.currency || 'GBP', }; } if (listing.externalId) { sku.seller_sku = listing.externalId; } product.skus = [sku]; return product; } export async function createItem(marketplace, listing) { logger.info(`Creating TikTok Shop product: ${listing.title}`); const productBody = mapListingToProduct(listing); const result = await makeRequest({ marketplace, method: 'POST', path: `/product/${API_VERSION}/products`, body: productBody, }); const productId = result?.product_id || result?.id; return { externalId: productId, url: '', }; } export async function updateItem(marketplace, listing) { const productId = listing.externalId; if (!productId) { throw new Error('Listing must have an externalId to update on TikTok Shop'); } logger.info(`Updating TikTok Shop product: ${productId}`); const productBody = mapListingToProduct(listing); await makeRequest({ marketplace, method: 'PUT', path: `/product/${API_VERSION}/products/${productId}`, body: productBody, }); return { externalId: productId }; } export async function syncItems(marketplace) { logger.info(`Syncing products from TikTok Shop marketplace: ${marketplace.name}`); const products = await fetchAllProducts(marketplace); const detailed = []; for (const product of products) { try { const detail = await fetchProductDetail(marketplace, product.id); detailed.push(detail); } catch (err) { logger.warn(`Failed to fetch detail for product ${product.id}: ${err.message}`); } } logger.info(`Fetched ${detailed.length} product(s) from TikTok Shop`); return detailed; } export async function syncOrders(marketplace, { startTime, endTime } = {}) { logger.info(`Syncing orders from TikTok Shop marketplace: ${marketplace.name}`); const orders = await fetchAllOrders(marketplace, { startTime, endTime }); const detailed = []; for (const order of orders) { try { const detail = await fetchOrderDetail(marketplace, order.id); detailed.push(detail); } catch (err) { logger.warn(`Failed to fetch detail for order ${order.id}: ${err.message}`); } } logger.info(`Fetched ${detailed.length} order(s) from TikTok Shop`); return detailed; } const ORDER_STATUS_MAP = { UNPAID: 'draft', ON_HOLD: 'draft', AWAITING_SHIPMENT: 'confirmed', PARTIALLY_SHIPPING: 'partiallyShipped', AWAITING_COLLECTION: 'shipped', IN_TRANSIT: 'shipped', DELIVERED: 'delivered', COMPLETED: 'completed', CANCELLED: 'cancelled', }; export function mapOrderStatus(tiktokStatus) { return ORDER_STATUS_MAP[tiktokStatus] || 'draft'; } export function mapOrderToSalesOrder(tiktokOrder) { const paymentInfo = tiktokOrder.payment || {}; const totalAmount = parseFloat(paymentInfo.product_total_price || 0); const shippingAmount = parseFloat(paymentInfo.shipping_fee || 0); const totalTax = parseFloat(paymentInfo.tax || 0); return { externalId: tiktokOrder.id, state: { type: mapOrderStatus(tiktokOrder.status) }, totalAmount, totalAmountWithTax: totalAmount + totalTax, shippingAmount, shippingAmountWithTax: shippingAmount, grandTotalAmount: parseFloat(paymentInfo.total_amount || 0), totalTaxAmount: totalTax, }; } const PRODUCT_STATUS_MAP = { DRAFT: 'draft', PENDING: 'draft', LIVE: 'active', SELLER_DEACTIVATED: 'inactive', PLATFORM_DEACTIVATED: 'suspended', FROZEN: 'suspended', DELETED: 'deleted', }; export function mapProductToListing(tiktokProduct) { const firstSku = tiktokProduct.skus?.[0]; const price = firstSku?.price?.sale_price ? parseFloat(firstSku.price.sale_price) : firstSku?.price?.original_price ? parseFloat(firstSku.price.original_price) : undefined; const currency = firstSku?.price?.currency || undefined; const totalInventory = (tiktokProduct.skus || []).reduce( (sum, sku) => sum + (sku.inventory?.[0]?.quantity || 0), 0 ); const varients = (tiktokProduct.skus || []).map((sku) => { const skuPrice = sku.price?.sale_price ? parseFloat(sku.price.sale_price) : sku.price?.original_price ? parseFloat(sku.price.original_price) : undefined; return { _reference: sku.id || sku.seller_sku || '', price: skuPrice, currency: sku.price?.currency || currency, state: { type: PRODUCT_STATUS_MAP[tiktokProduct.status] || 'draft' }, }; }); return { _reference: tiktokProduct.id, title: tiktokProduct.title || '', state: { type: PRODUCT_STATUS_MAP[tiktokProduct.status] || 'draft' }, price, currency, inventory: totalInventory, url: tiktokProduct.url || '', varients, }; } export function mapBuyerToClient(tiktokOrder) { const buyer = tiktokOrder.buyer_info || {}; const address = tiktokOrder.recipient_address || {}; return { name: buyer.name || address.name || 'Unknown', email: buyer.email || '', phone: buyer.phone_number || address.phone_number || '', address: { addressLine1: address.address_line1 || '', addressLine2: address.address_line2 || '', city: address.city || '', state: address.state || '', postcode: address.zipcode || '', country: address.region_code || '', }, }; } export async function handleWebhook(marketplace, event) { const { type, data } = event; logger.info(`TikTok Shop webhook received: ${type} for marketplace ${marketplace.name}`); switch (type) { case 'ORDER_STATUS_CHANGE': return { action: 'orderUpdate', orderId: data.order_id, status: data.order_status }; case 'ORDER_CREATION': return { action: 'orderCreate', orderId: data.order_id }; case 'PRODUCT_STATUS_CHANGE': return { action: 'productUpdate', productId: data.product_id, status: data.product_status }; case 'CANCELLATION_REQUEST': return { action: 'orderCancel', orderId: data.order_id }; case 'RETURN_REQUEST': return { action: 'returnRequest', orderId: data.order_id, returnId: data.return_id }; default: logger.debug(`Unhandled TikTok Shop webhook type: ${type}`); return { action: 'unknown', type }; } } export function canVerifyWebhookSignature(marketplace) { return !!marketplace.config?.appSecret; } export function verifyWebhookSignature(marketplace, body, signature) { const appSecret = marketplace.config?.appSecret; if (!appSecret) { return false; } const computed = crypto.createHmac('sha256', appSecret).update(body).digest('hex'); try { return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(signature)); } catch { return false; } }