663 lines
18 KiB
JavaScript

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;
}
}