$tokens]) : null; } function ordersmith_save_tokens(array $data, array $extra = [], string $option = ORDERSMITH_OPTION): void { $keep = array_intersect_key($data, array_flip(['access_token', 'access_token_expire_in', 'refresh_token', 'refresh_token_expire_in'])); $c = get_option($option) ?: []; unset($c['revoked']); // fresh tokens = reconnected update_option($option, array_merge($c, $extra, ['tokens' => ordersmith_encrypt(wp_json_encode($keep))]), false); } /** * Every connected shop, each tagged with its login ('option') and when that login connected. * Ordersmith has one TikTok login; Ordersmith Pro adds shops from more logins through the filter. */ function ordersmith_shops(): array { $c = ordersmith_connection(); $shops = []; foreach ($c['shops'] ?? [] as $s) { $shops[] = $s + ['option' => ORDERSMITH_OPTION, 'connected_at' => (int) ($c['connected_at'] ?? 0)]; } return apply_filters('ordersmith_shops', $shops); } /** A connected shop by 'id' or 'cipher', or null. */ function ordersmith_find_shop(string $key, string $value): ?array { foreach (ordersmith_shops() as $s) { if ((string) ($s[$key] ?? '') === $value) { return $s; } } return null; } /** Seller removed our access on TikTok's side: stop syncing and ask for a reconnect. Old orders stay. */ function ordersmith_revoked(string $why, string $option = ORDERSMITH_OPTION): void { $c = get_option($option); if (!is_array($c) || !empty($c['revoked'])) { return; } $c['revoked'] = time(); update_option($option, $c, false); if (!ordersmith_shops()) { as_unschedule_all_actions(ORDERSMITH_SYNC_HOOK, [], 'ordersmith'); // nothing left to sync } ordersmith_log('warning', sprintf('TikTok Shop access was removed for %s (%s). Its orders and stock are no longer syncing until it is reconnected.', $c['seller_name'] ?? 'a shop', $why)); do_action('ordersmith_revoked', $c['seller_name'] ?? '', $why); // signal for add-ons (e.g. Pro's email alerts) } add_action('admin_notices', function () { $c = get_option(ORDERSMITH_OPTION); // Revoked, or tokens unreadable (site moved and its WP salts changed). TikTok refresh tokens last ~100 years, // so plain expiry never needs a reconnect: an offline store just refreshes when it's back. if (!$c || !current_user_can('manage_woocommerce') || (empty($c['revoked']) && ordersmith_connection())) { return; } echo '

Ordersmith: your TikTok Shop is no longer connected, so orders and stock are not syncing. Reconnect TikTok Shop

'; }); /* ---------- signer + TikTok API ---------- */ function ordersmith_signer(string $route, array $payload) { $res = wp_remote_post(ORDERSMITH_SIGNER . '/' . $route, [ 'timeout' => 20, 'headers' => ['Content-Type' => 'application/json'], 'body' => wp_json_encode($payload), ]); if (is_wp_error($res)) { return $res; } $json = json_decode(wp_remote_retrieve_body($res), true); return is_array($json) ? $json : new WP_Error('ordersmith_signer', 'Signer returned an invalid response (HTTP ' . wp_remote_retrieve_response_code($res) . ').'); } /** Turn a TikTok auth response into an error or its data. */ function ordersmith_tiktok_data($res) { if (is_wp_error($res)) { return $res; } if (($res['code'] ?? -1) !== 0) { return new WP_Error('ordersmith_tiktok', sprintf('TikTok error %s: %s', $res['code'] ?? '?', $res['message'] ?? $res['error'] ?? 'unknown')); } return $res['data'] ?? []; } /** Access token for a login, refreshed when it expires within a day. */ function ordersmith_access_token(string $option = ORDERSMITH_OPTION) { $c = ordersmith_connection($option); if (!$c) { return new WP_Error('ordersmith_not_connected', 'TikTok Shop is not connected.'); } $t = $c['tokens']; if (($t['access_token_expire_in'] ?? 0) > time() + DAY_IN_SECONDS) { return $t['access_token']; } $res = ordersmith_signer('refresh', ['refresh_token' => $t['refresh_token']]); // Access tokens keep working after a seller cancels; the refresh is where TikTok says so (if the webhook was missed). if (!is_wp_error($res) && ($res['code'] ?? 0) === 36004006) { ordersmith_revoked('authorisation cancelled in TikTok Seller Center', $option); } $data = ordersmith_tiktok_data($res); if (is_wp_error($data)) { return $data; } ordersmith_save_tokens($data, [], $option); return $data['access_token']; } /** * Signed TikTok Shop API call. $path like '/order/202309/orders/search'. * Pass shop-scoped calls with ['shop_cipher' => ...] in $query; the token of that shop's login is used. */ function ordersmith_api(string $method, string $path, array $query = [], ?array $body = null, ?string $token = null) { $shop = isset($query['shop_cipher']) ? ordersmith_find_shop('cipher', (string) $query['shop_cipher']) : null; $token = $token ?? ordersmith_access_token($shop['option'] ?? ORDERSMITH_OPTION); if (is_wp_error($token)) { return $token; } $json = $body === null ? '' : wp_json_encode($body); $signed = ordersmith_signer('sign', ['path' => $path, 'query' => (object) $query, 'body' => $json]); if (is_wp_error($signed)) { return $signed; } if (empty($signed['query']['sign'])) { return new WP_Error('ordersmith_sign', 'Could not sign request: ' . ($signed['error'] ?? 'unknown')); } // Rate limited: TikTok sends HTTP 429 / 36009002 or HTTP 200 / 12052109. Short bursts clear in a second or two, // so retry in place (the signature stays valid for minutes); after that, callers reschedule. foreach ([0, 1, 3] as $wait) { sleep($wait); $res = wp_remote_request(ORDERSMITH_API . $path . '?' . http_build_query($signed['query']), [ 'method' => $method, 'timeout' => 30, 'headers' => ['x-tts-access-token' => $token, 'Content-Type' => 'application/json'], 'body' => $body === null ? null : $json, ]); if (is_wp_error($res)) { return $res; } $data = json_decode(wp_remote_retrieve_body($res), true); if (wp_remote_retrieve_response_code($res) !== 429 && !in_array($data['code'] ?? 0, [36009002, 12052109], true)) { return ordersmith_tiktok_data($data); } } return new WP_Error('ordersmith_rate_limited', 'TikTok is busy right now (rate limit). Try again in a minute.'); } function ordersmith_rate_limited($res): bool { return is_wp_error($res) && $res->get_error_code() === 'ordersmith_rate_limited'; } /* ---------- orders in ---------- */ // Logs go to WooCommerce → Status → Logs (source "ordersmith"); Woo deletes them after 30 days. function ordersmith_log(string $level, string $msg): void { wc_get_logger()->log($level, $msg, ['source' => 'ordersmith']); } // TikTok status → WooCommerce status. UNPAID is skipped (not a sale yet). const ORDERSMITH_STATUS = [ 'ON_HOLD' => 'on-hold', 'AWAITING_SHIPMENT' => 'processing', 'PARTIALLY_SHIPPING' => 'processing', 'AWAITING_COLLECTION' => 'processing', 'IN_TRANSIT' => 'processing', // Abe's call: stay Processing until TikTok confirms delivery 'DELIVERED' => 'completed', 'COMPLETED' => 'completed', 'CANCELLED' => 'cancelled', ]; const ORDERSMITH_SYNC_HOOK = 'ordersmith_sync_orders'; /** Hourly fallback poll when webhooks are live, every 15 min when they're not (e.g. a store TikTok can't reach). */ function ordersmith_sync_every(): int { return get_option('ordersmith_webhook_ok') ? HOUR_IN_SECONDS : 15 * MINUTE_IN_SECONDS; } function ordersmith_schedule_sync(bool $reschedule = false): void { if ($reschedule) { as_unschedule_all_actions(ORDERSMITH_SYNC_HOOK, [], 'ordersmith'); } if (!as_has_scheduled_action(ORDERSMITH_SYNC_HOOK, [], 'ordersmith')) { // Random offset so thousands of stores don't all hit the signer on the same minute. as_schedule_recurring_action(time() + wp_rand(60, ordersmith_sync_every()), ordersmith_sync_every(), ORDERSMITH_SYNC_HOOK, [], 'ordersmith'); } } add_action(ORDERSMITH_SYNC_HOOK, 'ordersmith_sync_orders'); /* ---------- webhooks: TikTok → signer → this store → immediate sync ---------- */ // Override for local testing behind a tunnel: define('ORDERSMITH_PUBLIC_URL', 'https://xyz.trycloudflare.com/woo-test') in wp-config.php. function ordersmith_notify_url(): string { $rest = rest_url('ordersmith/v1/notify'); return defined('ORDERSMITH_PUBLIC_URL') ? str_replace(untrailingslashit(site_url()), untrailingslashit(ORDERSMITH_PUBLIC_URL), $rest) : $rest; } /** Tell the signer where to forward this shop's events. Returns true when registered. */ function ordersmith_register_webhook(string $access_token): bool { $secret = get_option('ordersmith_webhook_secret'); if (!$secret) { $secret = wp_generate_password(48, false); update_option('ordersmith_webhook_secret', $secret, false); } $res = ordersmith_signer('register', ['access_token' => $access_token, 'notify_url' => ordersmith_notify_url(), 'secret' => $secret]); $ok = !is_wp_error($res) && !empty($res['ok']); if (!$ok) { ordersmith_log('warning', 'Instant sync not available (' . (is_wp_error($res) ? $res->get_error_message() : ($res['error'] ?? 'unknown')) . '). Orders will sync every 15 minutes instead.'); } update_option('ordersmith_webhook_ok', $ok ? 1 : 0, false); ordersmith_schedule_sync(true); return $ok; } add_action('rest_api_init', function () { register_rest_route('ordersmith/v1', '/notify', [ 'methods' => 'POST', 'permission_callback' => function (WP_REST_Request $req) { $secret = (string) get_option('ordersmith_webhook_secret'); // Header name is fixed by the signer ("X-OBR-Signature"); never rename it with the plugin. $sig = (string) $req->get_header('x_obr_signature'); return $secret !== '' && hash_equals(hash_hmac('sha256', $req->get_body(), $secret), $sig); }, 'callback' => function (WP_REST_Request $req) { $event = $req->get_json_params(); if ((int) ($event['type'] ?? 0) === 6) { // SELLER_DEAUTHORIZATION ordersmith_revoked('seller deauthorised the app', ordersmith_find_shop('id', (string) ($event['shop_id'] ?? ''))['option'] ?? ORDERSMITH_OPTION); return ['ok' => true]; } // Import right now instead of waiting for WP-Cron (which only wakes on page visits). // The signer hangs up after 2 s; ignore_user_abort lets the sync finish anyway. ignore_user_abort(true); ordersmith_sync_orders(); return ['ok' => true]; }, ]); }); add_action('ordersmith_sync_orders_now', 'ordersmith_sync_orders'); /** Pull orders changed since the last sync and create/update them in WooCommerce. Returns [created, updated] or WP_Error. */ /** * One sync at a time: two notifications arriving together must not import the same order twice. * If a sync is already running, queue one more run so changes it may have missed still arrive. */ function ordersmith_sync_orders() { if (!add_option('ordersmith_sync_lock', time(), '', false)) { // add_option is atomic: fails if the row exists if ((int) get_option('ordersmith_sync_lock') > time() - 5 * MINUTE_IN_SECONDS) { if (!as_has_scheduled_action('ordersmith_sync_orders_now', [], 'ordersmith')) { as_enqueue_async_action('ordersmith_sync_orders_now', [], 'ordersmith'); } return [0, 0]; } update_option('ordersmith_sync_lock', time(), false); // stale lock from a crashed run: take over } try { return ordersmith_run_sync(); } finally { delete_option('ordersmith_sync_lock'); } } function ordersmith_run_sync() { $shops = ordersmith_shops(); if (!$shops) { return new WP_Error('ordersmith_not_connected', 'Not connected.'); } // Per shop, so one failing shop neither blocks the others nor makes them re-read old orders. $last = get_option('ordersmith_shop_sync') ?: []; $started = time(); $counts = [0, 0]; $failed = null; foreach ($shops as $shop) { $since = (int) ($last[$shop['id']] ?? get_option('ordersmith_last_sync', time() - 3 * DAY_IN_SECONDS)); $token = ''; do { $query = ['shop_cipher' => $shop['cipher'], 'page_size' => '50'] + ($token ? ['page_token' => $token] : []); // 5 min overlap: an order updated during the last run is fetched again; unchanged ones are skipped. $res = ordersmith_api('POST', '/order/202309/orders/search', $query, ['update_time_ge' => $since - 300]); if (is_wp_error($res)) { ordersmith_log('error', 'Order sync failed for shop ' . $shop['name'] . ': ' . $res->get_error_message()); do_action('ordersmith_sync_failed', $shop, $res); ordersmith_retry_sync_if_limited($res); $failed = $res; continue 2; } foreach ($res['orders'] ?? [] as $o) { $r = ordersmith_upsert_order($o, $shop); if ($r === 'created') { $counts[0]++; } elseif ($r === 'updated') { $counts[1]++; } } $token = $res['next_page_token'] ?? ''; } while ($token !== ''); $err = ordersmith_sync_returns($shop, $since - 300); if (is_wp_error($err)) { ordersmith_log('error', 'Refund sync failed for shop ' . $shop['name'] . ': ' . $err->get_error_message()); do_action('ordersmith_sync_failed', $shop, $err); ordersmith_retry_sync_if_limited($err); $failed = $err; continue; } $last[$shop['id']] = $started; do_action('ordersmith_sync_ok', $shop); } update_option('ordersmith_shop_sync', $last, false); if ($failed) { return $failed; } update_option('ordersmith_last_sync', $started, false); // shown on the settings page: every shop is up to date return $counts; } /** Completed TikTok refunds/returns → WooCommerce refunds. Returns true or WP_Error. */ function ordersmith_sync_returns(array $shop, int $since) { $token = ''; do { $query = ['shop_cipher' => $shop['cipher'], 'page_size' => '50'] + ($token ? ['page_token' => $token] : []); $res = ordersmith_api('POST', '/return_refund/202309/returns/search', $query, ['update_time_ge' => $since]); if (is_wp_error($res)) { return $res; } foreach ($res['return_orders'] ?? [] as $r) { ordersmith_apply_return($r); } $token = $res['next_page_token'] ?? ''; } while ($token !== ''); return true; } function ordersmith_apply_return(array $r): void { // REPLACEMENT returns refund nothing; pending/rejected ones may never pay out. if (($r['return_status'] ?? '') !== 'RETURN_OR_REFUND_REQUEST_COMPLETE' || !in_array($r['return_type'] ?? '', ['REFUND', 'RETURN_AND_REFUND'], true)) { return; } $order = ordersmith_find_order((string) $r['order_id']); $done = $order ? ($order->get_meta('_ordersmith_returns') ?: []) : []; if (!$order || in_array((string) $r['return_id'], $done, true)) { return; // order from before we were connected, or already refunded } // ponytail: refund our own line values (what the seller earned), not TikTok's buyer-paid figures, // so Woo totals stay consistent with how the order was imported. One TikTok return line = one unit. $lines = []; $amount = 0.0; // Started from a Woo refund: the items are already refunded in Woo. $from_woo = in_array((string) $r['return_id'], $order->get_meta('_ordersmith_woo_returns') ?: [], true); foreach ($from_woo ? [] : $r['return_line_items'] ?? [] as $rl) { foreach ($order->get_items() as $item_id => $item) { if (strcasecmp((string) $item->get_meta('TikTok SKU'), trim((string) ($rl['seller_sku'] ?? ''))) === 0) { $unit = (float) $item->get_total() / max(1, $item->get_quantity()); $lines[$item_id]['qty'] = ($lines[$item_id]['qty'] ?? 0) + 1; $lines[$item_id]['refund_total'] = ($lines[$item_id]['refund_total'] ?? 0) + $unit; $amount += $unit; break; } } } if ((float) ($r['refund_amount']['refund_shipping_fee'] ?? 0) > 0) { foreach ($order->get_items('shipping') as $item_id => $ship) { $left = (float) $ship->get_total() - (float) $order->get_total_refunded_for_item($item_id, 'shipping'); if ($left > 0) { $lines[$item_id] = ['qty' => 0, 'refund_total' => $left]; $amount += $left; } } } $amount = min(round($amount, 2), (float) $order->get_remaining_refund_amount()); $restock = $r['return_type'] === 'RETURN_AND_REFUND'; // refund-only: the buyer kept the item if ($amount > 0) { $refund = wc_create_refund([ 'order_id' => $order->get_id(), 'amount' => $amount, 'reason' => sprintf('TikTok Shop %s %s: %s', $restock ? 'return' : 'refund', $r['return_id'], $r['return_reason_text'] ?? ''), 'line_items' => $lines, 'restock_items' => $restock, 'refund_payment' => false, // TikTok already paid the buyer 'ordersmith_from_tiktok' => true, // don't send it back to TikTok ]); if (is_wp_error($refund)) { ordersmith_log('error', 'Could not record refund ' . $r['return_id'] . ' for order ' . $r['order_id'] . ': ' . $refund->get_error_message()); return; // not marked done: retried next sync } ordersmith_log('info', sprintf('Recorded TikTok refund %s (%s) on order #%d.', $r['return_id'], wc_format_decimal($amount, 2), $order->get_id())); } $done[] = (string) $r['return_id']; $order = wc_get_order($order->get_id()); // the refund may have changed the status to Refunded $order->update_meta_data('_ordersmith_returns', $done); $order->save(); } /** * Rate limited, or the network blipped (DNS, timeout: WP_Http's 'http_request_failed'), mid-sync: * try again in a few minutes rather than waiting for the hourly poll. Nothing is lost either way. */ function ordersmith_retry_sync_if_limited($err): void { $temporary = ordersmith_rate_limited($err) || (is_wp_error($err) && $err->get_error_code() === 'http_request_failed'); if ($temporary && !as_has_scheduled_action('ordersmith_sync_orders_now', [], 'ordersmith')) { as_schedule_single_action(time() + wp_rand(60, 180), 'ordersmith_sync_orders_now', [], 'ordersmith'); } } function ordersmith_find_order(string $tiktok_id): ?WC_Order { // phpcs:ignore WordPress.DB.SlowDBQuery -- single indexed lookup per order; HPOS meta table indexes meta_key. $found = wc_get_orders(['limit' => 1, 'meta_key' => '_ordersmith_order_id', 'meta_value' => $tiktok_id, 'status' => 'any']); return $found[0] ?? null; } /** @return string 'created' | 'updated' | 'skipped' */ function ordersmith_upsert_order(array $o, array $shop): string { $wc_status = ORDERSMITH_STATUS[$o['status'] ?? ''] ?? null; if (!$wc_status) { return 'skipped'; // UNPAID or unknown } $order = ordersmith_find_order((string) $o['id']); if ($order && (int) $order->get_meta('_ordersmith_update_time') >= (int) $o['update_time']) { return 'skipped'; } $is_new = !$order; if ($is_new) { $order = wc_create_order(['created_via' => 'ordersmith']); $order->set_currency($o['payment']['currency'] ?? get_woocommerce_currency()); $order->set_payment_method_title('TikTok Shop'); $order->set_customer_ip_address(''); // wc_create_order records the importer's IP, not the buyer's $order->set_customer_user_agent(''); $order->set_date_created((int) $o['create_time']); $order->update_meta_data('_ordersmith_order_id', (string) $o['id']); $order->update_meta_data('_ordersmith_shop_id', (string) $shop['id']); ordersmith_add_items($order, $o); } ordersmith_set_address($order, $o); $order->update_meta_data('_ordersmith_update_time', (int) $o['update_time']); $order->update_meta_data('_ordersmith_status', $o['status']); $order->update_meta_data('_ordersmith_shipping_type', $o['shipping_type'] ?? ''); // SELLER = seller's own courier, TIKTOK = TikTok label $order->update_meta_data('_ordersmith_delivery_option_id', $o['delivery_option_id'] ?? ''); $order->update_meta_data('_ordersmith_package_id', (string) ($o['packages'][0]['id'] ?? '')); // Tracking appears once the package is arranged (TikTok label) or shipped (seller courier). Record it once. $li = $o['line_items'][0] ?? []; if (!empty($li['tracking_number']) && $li['tracking_number'] !== $order->get_meta('_ordersmith_tracking')) { $order->update_meta_data('_ordersmith_tracking', $li['tracking_number']); $order->update_meta_data('_ordersmith_carrier_name', $li['shipping_provider_name'] ?? ''); $order->add_order_note(sprintf('TikTok Shop tracking: %s (%s).', $li['tracking_number'], $li['shipping_provider_name'] ?? '')); } $order->calculate_totals(false); // Orders placed before the shop was connected: the seller already handled their stock by hand (that's why // they installed us), so import them for the record only. Mark stock as already reduced so Woo doesn't double-count. if ($is_new && (int) $o['create_time'] < (int) ($shop['connected_at'] ?? 0)) { $order->save(); // needs an ID first $order->get_data_store()->set_stock_reduced($order->get_id(), true); $order->add_order_note('Placed before Ordersmith was connected, so WooCommerce stock was not changed for this order.'); } // Never move backwards (e.g. seller completed it, TikTok later says AWAITING_COLLECTION). Cancelled always applies. $rank = ['pending' => 0, 'on-hold' => 1, 'processing' => 2, 'completed' => 3, 'refunded' => 4]; if ($wc_status !== 'cancelled' && ($rank[$order->get_status()] ?? 0) > ($rank[$wc_status] ?? 0)) { $wc_status = $order->get_status(); } // Woo reduces stock on on-hold/processing/completed and restores it on cancelled. $order->set_status($wc_status, $is_new ? 'Imported from TikTok Shop order ' . $o['id'] . '.' : 'TikTok Shop status: ' . $o['status'] . '.'); $order->save(); if ($wc_status === 'cancelled') { // Already cancelled in Woo by the seller (restock was held back, see below): restore now. Per-item, so never twice. wc_increase_stock_levels($order->get_id()); // by ID: reload, $order's items are stale if Woo's own cancel hook just restored } if ($is_new) { ordersmith_log('info', 'Imported TikTok order ' . $o['id'] . ' as #' . $order->get_id()); } return $is_new ? 'created' : 'updated'; } // Cancelled in Woo but still live on TikTok: restoring stock now would push the units back to TikTok while // the buyer still expects them, so hold it until TikTok confirms. (Ordersmith Pro cancels on TikTok first.) add_filter('woocommerce_can_restore_order_stock', function ($can, $order) { if ($can && $order->get_meta('_ordersmith_order_id') && $order->get_meta('_ordersmith_status') !== 'CANCELLED') { $order->add_order_note('Ordersmith: stock was NOT restored, because this order is still active on TikTok Shop. Cancel it in TikTok Seller Center; stock is restored once TikTok confirms.'); return false; } return $can; }, 10, 2); /** TikTok returns one line per unit; group by SKU and match WooCommerce products by SKU (case-insensitive). */ function ordersmith_add_items(WC_Order $order, array $o): void { $groups = []; foreach ($o['line_items'] ?? [] as $li) { $groups[$li['sku_id']][] = $li; } foreach ($groups as $lines) { $li = $lines[0]; $qty = count($lines); $sku = trim((string) ($li['seller_sku'] ?? '')); $product_id = $sku !== '' ? wc_get_product_id_by_sku($sku) : 0; // DB collation makes this case-insensitive // Seller's revenue: original price minus the seller's own discount (TikTok funds platform discounts). $subtotal = array_sum(array_map(fn($l) => (float) $l['original_price'], $lines)); $total = $subtotal - array_sum(array_map(fn($l) => (float) $l['seller_discount'], $lines)); $item = new WC_Order_Item_Product(); $item->set_props(['product_id' => $product_id, 'quantity' => $qty, 'subtotal' => $subtotal, 'total' => $total]); $item->set_name($li['product_name'] . ($li['sku_name'] && $li['sku_name'] !== 'Default' ? ' – ' . $li['sku_name'] : '')); $item->add_meta_data('TikTok SKU', $sku ?: '(none)'); $order->add_item($item); if (!$product_id) { $msg = sprintf('SKU "%s" (%s) is not in WooCommerce. The order was imported but stock was NOT reduced for this item. Add the SKU to the matching product.', $sku, $li['product_name']); $order->add_order_note($msg); ordersmith_log('warning', 'Order ' . $o['id'] . ': ' . $msg); do_action('ordersmith_unmatched_sku', $sku, (string) $li['product_name'], $order); } } $p = $o['payment'] ?? []; $ship = new WC_Order_Item_Shipping(); $ship->set_method_title($o['delivery_option_name'] ?? 'TikTok Shop shipping'); $ship->set_total((float) ($p['shipping_fee'] ?? 0)); $order->add_item($ship); // ponytail: no tax lines — UK prices include VAT and TikTok reports tax 0 here. Map item_tax when a market needs it. } /** Address is empty while ON_HOLD; filled in on a later sync once TikTok releases it. */ function ordersmith_set_address(WC_Order $order, array $o): void { $a = ($o['recipient_address'] ?? []) + ['first_name' => '', 'last_name' => '', 'name' => '', 'address_line1' => '', 'address_detail' => '', 'region_code' => '']; $fields = [ // Some regions only send "name"; then it all goes in first_name. 'first_name' => $a['first_name'] ?: $a['name'], 'last_name' => $a['first_name'] ? $a['last_name'] : '', 'address_1' => $a['address_line1'] ?: $a['address_detail'], 'address_2' => trim(implode(' ', array_filter([$a['address_line2'] ?? '', $a['address_line3'] ?? '', $a['address_line4'] ?? '']))), 'city' => $a['post_town'] ?? '', 'postcode' => $a['postal_code'] ?? '', 'country' => $a['region_code'] ?: 'GB', 'phone' => $a['phone_number'] ?? '', ]; $order->set_address($fields, 'shipping'); // No buyer email on purpose: TikTok forbids contacting buyers off-platform, and without one Woo sends them nothing. $order->set_address($fields, 'billing'); } /* ---------- tracking out (seller-shipped orders) ---------- */ function ordersmith_shop_cipher(WC_Order $order): ?string { return ordersmith_find_shop('id', (string) $order->get_meta('_ordersmith_shop_id'))['cipher'] ?? null; } /** Carriers TikTok accepts for this order's delivery option, as [id => name]. Cached for a day. */ function ordersmith_carriers(WC_Order $order): array { $opt = (string) $order->get_meta('_ordersmith_delivery_option_id'); $cipher = ordersmith_shop_cipher($order); if ($opt === '' || !$cipher) { return []; } $key = 'ordersmith_carriers_' . md5($cipher . $opt); $list = get_transient($key); if ($list === false) { $res = ordersmith_api('GET', "/logistics/202309/delivery_options/$opt/shipping_providers", ['shop_cipher' => $cipher]); if (is_wp_error($res)) { ordersmith_log('error', 'Could not load carriers: ' . $res->get_error_message()); return []; } $list = array_column($res['shipping_providers'] ?? [], 'name', 'id'); set_transient($key, $list, DAY_IN_SECONDS); } return $list; } function ordersmith_can_ship(WC_Order $order): bool { return $order->get_meta('_ordersmith_shipping_type') === 'SELLER' && in_array($order->get_meta('_ordersmith_status'), ['AWAITING_SHIPMENT', 'PARTIALLY_SHIPPING'], true) && !$order->get_meta('_ordersmith_shipped_at'); } add_action('add_meta_boxes', function () { $screen = function_exists('wc_get_page_screen_id') ? wc_get_page_screen_id('shop-order') : 'shop_order'; add_meta_box('ordersmith_tiktok', 'TikTok Shop', 'ordersmith_render_meta_box', $screen, 'side', 'high'); }); function ordersmith_render_meta_box($post_or_order): void { $order = $post_or_order instanceof WC_Order ? $post_or_order : wc_get_order($post_or_order->ID); if (!$order || !$order->get_meta('_ordersmith_order_id')) { echo '

Not a TikTok Shop order.

'; return; } echo '

TikTok order ' . esc_html($order->get_meta('_ordersmith_order_id')) . '
Status: ' . esc_html($order->get_meta('_ordersmith_status')) . '

'; if ($order->get_meta('_ordersmith_status') === 'CANCELLED') { echo '

This order is cancelled on TikTok Shop.

'; return; } wp_nonce_field('ordersmith_tracking', 'ordersmith_tracking_nonce'); do_action('ordersmith_order_box', $order); // Ordersmith Pro adds its cancel/refund options here // One quiet line, only where Pro would act (before shipping, or after delivery), and only without Pro. if (!has_action('ordersmith_order_box') && in_array($order->get_meta('_ordersmith_status'), ['AWAITING_SHIPMENT', 'AWAITING_COLLECTION', 'DELIVERED', 'COMPLETED'], true)) { echo '

Cancel or refund on TikTok from here with Ordersmith Pro.

'; } if ($at = $order->get_meta('_ordersmith_shipped_at')) { echo '

✔ Tracking ' . esc_html($order->get_meta('_ordersmith_tracking')) . ' sent to TikTok ' . esc_html(human_time_diff((int) $at)) . ' ago.

'; return; } if ($order->get_meta('_ordersmith_shipping_type') !== 'SELLER') { ordersmith_render_tiktok_shipping($order); return; } if (!ordersmith_can_ship($order)) { echo '

Tracking can be sent once TikTok marks this order "Awaiting shipment".

'; return; } $carriers = ordersmith_carriers($order); $sel = (string) $order->get_meta('_ordersmith_carrier_id'); echo '

'; echo '

'; echo '

Set the order to Completed and save. The tracking number is sent to TikTok.

'; } // Save the fields (priority 10 runs before Woo applies the status change at 40, so the completed/cancelled hooks see them). add_action('woocommerce_process_shop_order_meta', function ($order_id) { if (!isset($_POST['ordersmith_tracking_nonce']) || !wp_verify_nonce(sanitize_key($_POST['ordersmith_tracking_nonce']), 'ordersmith_tracking') || !current_user_can('edit_shop_orders')) { return; } $order = wc_get_order($order_id); foreach (['ordersmith_carrier_id', 'ordersmith_tracking', 'ordersmith_cancel_reason'] as $field) { if (isset($_POST[$field])) { $order->update_meta_data('_' . $field, sanitize_text_field(wp_unslash($_POST[$field]))); } } $order->save_meta_data(); }, 10); add_action('woocommerce_order_status_completed', function ($order_id) { $order = wc_get_order($order_id); if ($order && ordersmith_can_ship($order)) { ordersmith_send_tracking($order); } }); /** Mark the whole TikTok order shipped with the seller's carrier + tracking. */ function ordersmith_send_tracking(WC_Order $order): bool { $tracking = trim((string) $order->get_meta('_ordersmith_tracking')); $carrier = (string) $order->get_meta('_ordersmith_carrier_id'); $tt_id = (string) $order->get_meta('_ordersmith_order_id'); $fail = function (string $why) use ($order, $tt_id) { ordersmith_log('error', "Tracking for order $tt_id not sent: $why"); // Back to Processing so an unshipped TikTok order can't hide behind "Completed". $order->update_status('processing', 'TikTok Shop: tracking NOT sent. ' . $why); return false; }; if ($tracking === '' || $carrier === '') { return $fail('Choose a carrier and enter a tracking number in the TikTok Shop box, then save again.'); } $cipher = ordersmith_shop_cipher($order); $detail = ordersmith_api('GET', '/order/202309/orders', ['shop_cipher' => $cipher, 'ids' => $tt_id]); if (is_wp_error($detail)) { return $fail($detail->get_error_message()); } $line_ids = array_column($detail['orders'][0]['line_items'] ?? [], 'id'); $res = ordersmith_api('POST', "/fulfillment/202309/orders/$tt_id/packages", ['shop_cipher' => $cipher], [ 'order_line_item_ids' => $line_ids, 'tracking_number' => $tracking, 'shipping_provider_id' => $carrier, ]); if (is_wp_error($res)) { return $fail($res->get_error_message()); } $order->update_meta_data('_ordersmith_shipped_at', time()); $order->add_order_note(sprintf('TikTok Shop: tracking %s sent (%s).', $tracking, ordersmith_carriers($order)[$carrier] ?? $carrier)); $order->save(); ordersmith_log('info', "Tracking sent for order $tt_id"); return true; } /* ---------- TikTok-shipped orders: arrange shipment + print label ---------- */ function ordersmith_render_tiktok_shipping(WC_Order $order): void { $id = $order->get_id(); if ($trk = $order->get_meta('_ordersmith_tracking')) { echo '

Shipping with TikTok label
Carrier: ' . esc_html($order->get_meta('_ordersmith_carrier_name')) . '
Tracking: ' . esc_html($trk) . '

'; $url = wp_nonce_url(admin_url('admin-post.php?action=ordersmith_label&order_id=' . $id), 'ordersmith_label_' . $id); echo '

Print shipping label

'; return; } if ($order->get_meta('_ordersmith_status') !== 'AWAITING_SHIPMENT' || !$order->get_meta('_ordersmith_package_id')) { echo '

Shipping with a TikTok label. You can arrange shipment once TikTok marks this order "Awaiting shipment".

'; return; } $url = admin_url('admin-post.php'); // Not a
: this box sits inside the order edit form, and nested forms break it. echo '


'; echo '

'; echo '

'; // The real form is printed outside the order form, in the admin footer. add_action('admin_footer', function () use ($url, $id) { echo ''; wp_nonce_field('ordersmith_arrange_' . $id); echo '
'; }); } /** Re-fetch one order from TikTok and apply it (status, tracking). */ function ordersmith_refresh_order(WC_Order $order): void { $d = ordersmith_api('GET', '/order/202309/orders', ['shop_cipher' => ordersmith_shop_cipher($order), 'ids' => $order->get_meta('_ordersmith_order_id')]); if (!is_wp_error($d) && !empty($d['orders'][0])) { $shop = ['id' => $order->get_meta('_ordersmith_shop_id')]; ordersmith_upsert_order($d['orders'][0], $shop); } } function ordersmith_order_from_request(string $nonce_action): WC_Order { $id = absint($_REQUEST['order_id'] ?? 0); if (!current_user_can('edit_shop_orders') || !wp_verify_nonce(sanitize_key($_REQUEST['_wpnonce'] ?? ''), $nonce_action . $id)) { wp_die('Not allowed.'); } $order = wc_get_order($id); if (!$order || !$order->get_meta('_ordersmith_order_id')) { wp_die('Not a TikTok Shop order.'); } return $order; } add_action('admin_post_ordersmith_arrange', function () { $order = ordersmith_order_from_request('ordersmith_arrange_'); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked in ordersmith_order_from_request() above. $handover = sanitize_key(wp_unslash($_POST['ordersmith_handover'] ?? '')) === 'pickup' ? 'PICKUP' : 'DROP_OFF'; $pkg = $order->get_meta('_ordersmith_package_id'); $res = ordersmith_api('POST', "/fulfillment/202309/packages/$pkg/ship", ['shop_cipher' => ordersmith_shop_cipher($order)], ['handover_method' => $handover]); if (is_wp_error($res)) { $order->add_order_note('TikTok Shop: could not arrange shipment. ' . $res->get_error_message()); ordersmith_log('error', 'Arrange shipment failed for ' . $order->get_meta('_ordersmith_order_id') . ': ' . $res->get_error_message()); } else { $order->add_order_note('TikTok Shop: shipment arranged (' . strtolower(str_replace('_', ' ', $handover)) . ').'); ordersmith_refresh_order($order); // pulls the tracking number so the label button appears } wp_safe_redirect($order->get_edit_order_url()); exit; }); add_action('admin_post_ordersmith_label', function () { $order = ordersmith_order_from_request('ordersmith_label_'); $pkg = $order->get_meta('_ordersmith_package_id'); // Label URLs expire, so fetch a fresh one on every click. $doc = ordersmith_api('GET', "/fulfillment/202309/packages/$pkg/shipping_documents", ['shop_cipher' => ordersmith_shop_cipher($order), 'document_type' => 'SHIPPING_LABEL']); if (is_wp_error($doc) || empty($doc['doc_url'])) { wp_die('Could not get the label from TikTok: ' . esc_html(is_wp_error($doc) ? $doc->get_error_message() : 'no document returned') . '. Try again in a minute, or print it in TikTok Seller Center.'); } // Label lives on TikTok's CDN: allow exactly that host for this one redirect. $host = (string) wp_parse_url($doc['doc_url'], PHP_URL_HOST); add_filter('allowed_redirect_hosts', fn($hosts) => array_merge($hosts, [$host])); wp_safe_redirect(esc_url_raw($doc['doc_url'])); exit; }); /* ---------- stock out: WooCommerce is the source of truth ---------- */ // Fires for every stock change: orders, refunds, admin edits, REST, imports. add_action('woocommerce_product_set_stock', 'ordersmith_queue_stock'); add_action('woocommerce_variation_set_stock', 'ordersmith_queue_stock'); function ordersmith_queue_stock(WC_Product $product): void { if (!$product->get_sku() || !$product->managing_stock() || !ordersmith_shops()) { return; } // Async + de-duplicated: an order with 5 units of one product pushes once. $args = [$product->get_id()]; if (!as_has_scheduled_action('ordersmith_push_stock', $args, 'ordersmith')) { as_enqueue_async_action('ordersmith_push_stock', $args, 'ordersmith'); } } add_action('ordersmith_push_stock', 'ordersmith_push_stock'); /** Find this SKU on TikTok: [product_id, sku_id, warehouse_id], null if not listed, or WP_Error. Cached on the product. */ function ordersmith_tiktok_sku(WC_Product $product, array $shop, bool $fresh = false) { $map = $product->get_meta('_ordersmith_tt_map') ?: []; $hit = $map[$shop['id']] ?? null; if (!$fresh && $hit !== null) { if (isset($hit['missing'])) { if ($hit['missing'] > time() - DAY_IN_SECONDS) { return null; // not on TikTok as of today; check again tomorrow in case it gets listed } } else { return $hit; } } $res = ordersmith_api('POST', '/product/202309/products/search', ['shop_cipher' => $shop['cipher'], 'page_size' => '10'], ['seller_skus' => [$product->get_sku()]]); if (is_wp_error($res)) { return $res; // don't cache failures } $found = []; foreach ($res['products'] ?? [] as $p) { foreach ($p['skus'] ?? [] as $s) { if (strcasecmp(trim($s['seller_sku'] ?? ''), trim($product->get_sku())) === 0) { // ponytail: first warehouse only. Multi-warehouse sellers need a warehouse picker. $found = [$p['id'], $s['id'], $s['inventory'][0]['warehouse_id'] ?? '']; break 2; } } } $map[$shop['id']] = $found ?: ['missing' => time()]; $product->update_meta_data('_ordersmith_tt_map', $map); $product->save_meta_data(); return $found ?: null; } function ordersmith_push_stock(int $product_id): void { $product = wc_get_product($product_id); if (!$product || !$product->managing_stock()) { return; } $qty = max(0, (int) $product->get_stock_quantity()); foreach (ordersmith_shops() as $shop) { $res = null; foreach ([false, true] as $fresh) { // second pass: product may have been re-listed on TikTok with new IDs $tt = ordersmith_tiktok_sku($product, $shop, $fresh); if (is_wp_error($tt)) { $res = $tt; break; } if (!$tt) { break; // not sold on TikTok, nothing to do } [$tt_product, $tt_sku, $warehouse] = $tt; $res = ordersmith_api('POST', "/product/202309/products/$tt_product/inventory/update", ['shop_cipher' => $shop['cipher']], [ 'skus' => [['id' => $tt_sku, 'inventory' => [['warehouse_id' => $warehouse, 'quantity' => $qty]]]], ]); if (!is_wp_error($res)) { ordersmith_log('info', sprintf('Stock for %s set to %d on TikTok.', $product->get_sku(), $qty)); break; } } if (ordersmith_rate_limited($res)) { // Absolute quantity, so a later run is always right: reads the stock as it is then. as_schedule_single_action(time() + wp_rand(60, 180), 'ordersmith_push_stock', [$product_id], 'ordersmith'); ordersmith_log('notice', sprintf('TikTok rate limit: stock for %s will be retried in a few minutes.', $product->get_sku())); return; } if (is_wp_error($res)) { ordersmith_log('error', sprintf('Could not update TikTok stock for %s: %s', $product->get_sku(), $res->get_error_message())); do_action('ordersmith_stock_push_failed', $product, $res, $shop); throw new Exception(esc_html($res->get_error_message())); // Action Scheduler marks it failed (visible in Tools → Scheduled Actions) } } } add_action('admin_post_ordersmith_push_all_stock', function () { if (!current_user_can('manage_woocommerce')) { wp_die('Not allowed.'); } check_admin_referer('ordersmith_push_all_stock'); $ids = wc_get_products(['limit' => -1, 'return' => 'ids', 'type' => ['simple', 'variation'], 'manage_stock' => true]); $n = 0; foreach ($ids as $id) { $p = wc_get_product($id); if ($p && $p->get_sku()) { $p->delete_meta_data('_ordersmith_tt_map'); // re-match everything $p->save_meta_data(); ordersmith_queue_stock($p); $n++; } } ordersmith_back(sprintf('Sending stock for %d products to TikTok in the background. Products not listed on TikTok are skipped.', $n)); }); /* ---------- admin page: connect / disconnect ---------- */ add_action('admin_menu', function () { add_submenu_page('woocommerce', 'Ordersmith', 'Ordersmith', 'manage_woocommerce', 'ordersmith', 'ordersmith_render_page'); }); function ordersmith_page_url(array $args = []): string { return add_query_arg($args, admin_url('admin.php?page=ordersmith')); } function ordersmith_render_page(): void { $c = ordersmith_connection(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- TikTok's redirect can't carry a nonce; the code is only used after the admin confirms via a nonce-protected form. $code = isset($_GET['code']) ? sanitize_text_field(wp_unslash($_GET['code'])) : ''; $notice = get_transient('ordersmith_notice_' . get_current_user_id()); delete_transient('ordersmith_notice_' . get_current_user_id()); $state = rtrim(strtr(base64_encode(wp_json_encode(['site' => untrailingslashit(site_url())])), '+/', '-_'), '='); ?>

Ordersmith for TikTok Shop

TikTok Shop has authorised this store. Confirm to finish connecting.

Connected as .

ShopRegion

Last sync: . Problems are recorded in WooCommerce → Status → Logs.

Ordersmith Pro: connect more than one TikTok Shop, cancel and refund TikTok orders from WooCommerce, and sync prices, titles and descriptions to TikTok Shop. Learn more

Connect your TikTok Shop to import orders, send tracking numbers and keep stock in sync.

Connect TikTok Shop

$msg, 'error' => $error], MINUTE_IN_SECONDS); wp_safe_redirect(ordersmith_page_url()); exit; } add_action('admin_post_ordersmith_connect', function () { if (!current_user_can('manage_woocommerce')) { wp_die('Not allowed.'); } check_admin_referer('ordersmith_connect'); $code = sanitize_text_field(wp_unslash($_POST['code'] ?? '')); $data = ordersmith_tiktok_data(ordersmith_signer('token', ['auth_code' => $code])); if (is_wp_error($data)) { ordersmith_back('Could not connect: ' . $data->get_error_message() . ' Try connecting again.', true); } $shops = ordersmith_api('GET', '/authorization/202309/shops', [], null, $data['access_token']); if (is_wp_error($shops)) { ordersmith_back('Connected, but could not read your shops: ' . $shops->get_error_message(), true); } $list = array_map(fn($s) => array_intersect_key($s, array_flip(['id', 'name', 'region', 'cipher', 'code'])), $shops['shops'] ?? []); ordersmith_save_tokens($data, ['seller_name' => $data['seller_name'] ?? '', 'shops' => $list, 'connected_at' => time()]); ordersmith_register_webhook($data['access_token']); // also (re)schedules the poll ordersmith_back('TikTok Shop connected.'); }); // Re-arm the schedule if it went missing (e.g. plugin deactivated and reactivated). add_action('admin_init', function () { $c = get_option(ORDERSMITH_OPTION); if ($c && empty($c['revoked'])) { // Stores connected before webhooks existed: register once. if (get_option('ordersmith_webhook_ok') === false && !is_wp_error($t = ordersmith_access_token())) { ordersmith_register_webhook($t); } ordersmith_schedule_sync(); } }); add_action('admin_post_ordersmith_sync_now', function () { if (!current_user_can('manage_woocommerce')) { wp_die('Not allowed.'); } check_admin_referer('ordersmith_sync_now'); $r = ordersmith_sync_orders(); is_wp_error($r) ? ordersmith_back('Sync failed: ' . $r->get_error_message(), true) : ordersmith_back(sprintf('Sync done: %d new, %d updated.', $r[0], $r[1])); }); add_action('admin_post_ordersmith_disconnect', function () { if (!current_user_can('manage_woocommerce')) { wp_die('Not allowed.'); } check_admin_referer('ordersmith_disconnect'); $token = ordersmith_access_token(); if (!is_wp_error($token)) { ordersmith_signer('unregister', ['access_token' => $token]); // stop forwarding this shop's events } delete_option(ORDERSMITH_OPTION); // tokens gone immediately, as promised in the privacy policy delete_option('ordersmith_webhook_ok'); as_unschedule_all_actions(ORDERSMITH_SYNC_HOOK, [], 'ordersmith'); ordersmith_back('Disconnected. You can also revoke access in TikTok Shop Seller Center.'); });