663 lines
18 KiB
JavaScript
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;
|
|
}
|
|
}
|