diff --git a/app/actions/wc-sync.ts b/app/actions/wc-sync.ts
index ae481c75..b313340f 100644
--- a/app/actions/wc-sync.ts
+++ b/app/actions/wc-sync.ts
@@ -75,6 +75,23 @@ const SYNC_SETTING_KEYS = [
'wc_fx_push_enabled', 'last_wc_fx_push_at',
]
+// Machine-managed sync state — written by the import / sync / webhook jobs, never
+// by the settings form. Excluded from saveSyncSettings so a settings save can't
+// silently wipe the initial-import-completed flag or the sync cursors (which would
+// turn live order sync back off).
+const MACHINE_MANAGED_SYNC_KEYS = new Set([
+ 'wc_initial_import_completed',
+ 'wc_webhook_last_received_at',
+ 'wc_order_webhook_last_received_at',
+ 'wc_product_webhook_last_received_at',
+ 'last_wc_order_sync_at',
+ 'last_wc_order_reconcile_at',
+ 'last_wc_product_sync_at',
+ 'last_wc_product_reconcile_at',
+ 'last_wc_stock_sync_at',
+ 'last_wc_fx_push_at',
+])
+
const SYNC_DEFAULTS: WcSyncSettings = {
wc_sync_enabled: 'false',
wc_sync_order_statuses: '["processing"]',
@@ -210,7 +227,7 @@ export async function saveWcSyncSettings(data: Partial): Promise
})
if (!gate.ok) return { success: false, error: gate.error }
const ops = Object.entries(data)
- .filter((entry): entry is [string, string] => SYNC_SETTING_KEYS.includes(entry[0]) && typeof entry[1] === 'string')
+ .filter((entry): entry is [string, string] => SYNC_SETTING_KEYS.includes(entry[0]) && !MACHINE_MANAGED_SYNC_KEYS.has(entry[0]) && typeof entry[1] === 'string')
.map(([k, v]) =>
db.setting.upsert({
where: { key: k },
diff --git a/lib/connectors/woocommerce/sync/initial-import.ts b/lib/connectors/woocommerce/sync/initial-import.ts
index 5a318f50..5da6981d 100644
--- a/lib/connectors/woocommerce/sync/initial-import.ts
+++ b/lib/connectors/woocommerce/sync/initial-import.ts
@@ -32,6 +32,24 @@ export type InitialImportProgress = {
const JOB_KEY = 'initial_order_import_job'
+/**
+ * Decide whether an initial-import pass counts as COMPLETE (which unlocks ongoing
+ * live order sync) or FAILED. A pass that errored on every order and imported
+ * nothing must NOT count as complete — otherwise the UI shows a dead-end
+ * "completed" with no orders, live sync stays silently gated off, and there's no
+ * way to retry. A pass that imported/reconciled at least one order, or had no
+ * active orders to import, is complete (per-order errors are surfaced but don't
+ * block, since live sync of new orders can still proceed).
+ */
+export function decideInitialImportOutcome(input: {
+ imported: number
+ skipped: number
+ errorCount: number
+}): 'complete' | 'failed' {
+ const madeProgress = input.imported > 0 || input.skipped > 0
+ return input.errorCount > 0 && !madeProgress ? 'failed' : 'complete'
+}
+
const INITIAL_PROGRESS: InitialImportProgress = {
status: 'idle',
message: '',
@@ -164,6 +182,38 @@ async function runInitialImport(progress: InitialImportProgress) {
// -----------------------------------------------------------------------
// Completion
// -----------------------------------------------------------------------
+ const outcome = decideInitialImportOutcome({
+ imported: progress.activeOrdersImported,
+ skipped: progress.activeOrdersSkipped,
+ errorCount: progress.errors.length,
+ })
+
+ if (outcome === 'failed') {
+ // Every order errored and nothing was imported \u2014 a systemic failure (e.g.
+ // no storefront-synced warehouse). Do NOT mark the import complete: that
+ // would falsely unlock live sync and leave a dead-end "done" state with no
+ // retry. Surface it as an error so the UI shows Retry and live order sync
+ // stays gated off until a real import succeeds.
+ progress.status = 'error'
+ progress.message = `Import failed \u2014 0 of ${progress.totalOrders} order${progress.totalOrders === 1 ? '' : 's'} imported (${progress.errors.length} error${progress.errors.length === 1 ? '' : 's'}). Resolve the cause and retry; live order sync stays off until the initial import succeeds.`
+ await saveProgress(progress)
+
+ await logActivity({
+ entityType: 'IMPORT',
+ tag: 'import',
+ action: 'failed',
+ description: `Active WC order import failed: ${progress.message}`,
+ resolveUser: false,
+ })
+ notify({
+ type: 'error',
+ title: 'Active Order Import Failed',
+ message: progress.message,
+ actionUrl: '/sync',
+ })
+ return
+ }
+
await db.setting.upsert({
where: { key: 'wc_initial_import_completed' },
create: { key: 'wc_initial_import_completed', value: 'true' },
diff --git a/tests/wc-initial-import-outcome.test.ts b/tests/wc-initial-import-outcome.test.ts
new file mode 100644
index 00000000..7cefbffc
--- /dev/null
+++ b/tests/wc-initial-import-outcome.test.ts
@@ -0,0 +1,21 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { decideInitialImportOutcome } from '../lib/connectors/woocommerce/sync/initial-import.ts'
+
+test('a pass that imported nothing and errored on everything is FAILED (no false-complete)', () => {
+ // The reported bug: 6 orders, all errored ("no storefront-synced warehouse"),
+ // 0 imported. Must NOT count as complete — otherwise live sync silently stays
+ // off with a dead-end "completed" and no retry.
+ assert.equal(decideInitialImportOutcome({ imported: 0, skipped: 0, errorCount: 6 }), 'failed')
+ assert.equal(decideInitialImportOutcome({ imported: 0, skipped: 0, errorCount: 1 }), 'failed')
+})
+
+test('no active orders to import is COMPLETE (legitimately ready for live sync)', () => {
+ assert.equal(decideInitialImportOutcome({ imported: 0, skipped: 0, errorCount: 0 }), 'complete')
+})
+
+test('any real progress makes it COMPLETE even with some per-order errors', () => {
+ assert.equal(decideInitialImportOutcome({ imported: 4, skipped: 0, errorCount: 2 }), 'complete') // partial import
+ assert.equal(decideInitialImportOutcome({ imported: 0, skipped: 3, errorCount: 2 }), 'complete') // all already imported
+ assert.equal(decideInitialImportOutcome({ imported: 5, skipped: 0, errorCount: 0 }), 'complete') // clean import
+})