@tallyui/connector-woocommerce 3.0.0-next.0 → 3.0.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,11 +30,13 @@ The incremental pull can miss an edit: a `modified_after` that over-excludes (in
30
30
  - A product the till holds but the listing omits is removed only when a by-id re-read (`include=`, `status=any`) finds it gone, trashed or unpublished, and within the runner's mass-delete brake.
31
31
  - A product hidden from the POS after it synced (WCPOS "online only" visibility, with POS-only products turned on) is removed from the till by the pass; hiding many at once is held by the mass-delete brake.
32
32
  - An existing install keeps its pull checkpoint: the combined pull reads it under `legacyKey: 'products'`.
33
- - The WCPOS bulk-id fast path is dormant: it is chosen only when `status.capabilities` includes `products_id_fast_path` (wcpos/woocommerce-pos#2113), and until its id-to-uuid mapping exists the listing stays paged.
33
+ - When `wcpos/v2/status` lists `products_id_fast_path` in `capabilities`, the whole catalogue is listed in one request (`per_page=-1` with `_fields=id,date_modified_gmt,stock_quantity,stock_status`) instead of pages of 100; a store that refuses it (a 400) is listed page by page in the same pass.
34
+ - Both listings key on the numeric product id, never the till-local uuid; the adapter's `matchKey` is how the runner finds the till's copy.
34
35
 
35
36
  ## Errors
36
37
 
37
- - `ConnectorUnauthorizedError` (`code: 'unauthorized'`), from `@tallyui/core` and re-exported here: the store rejected the token (401 or 403), or `WooMissingTokenError` (a subclass) was thrown for a missing token. The app should sign in again.
38
+ - `ConnectorUnauthorizedError`, from `@tallyui/core` and re-exported here: the store refused the request (401: the token is missing, expired or revoked; 403: signed in but not allowed), or `WooMissingTokenError` (a subclass) was thrown for a missing token. `error.status` is `401` (`code: 'unauthorized'`, sign in again) or `403` (`code: 'forbidden'`, signed in but not allowed: the pull retries on the store schedule; don't sign out).
39
+ - `WooPluginUpdateRequiredError` (`code: 'unsupported_store'`, `fixedBy: 'store'`): the store answered a `jwt_auth_*` 403, which WCPOS before 1.10.8 sends for a valid token (wcpos#1863). The store owner must update WCPOS to `minVersion` (`1.10.8`); the pull retries on the store schedule and the till stays signed in.
38
40
  - `WooDateFilterError` (`code: 'unsupported_store'`): the store returned a product outside the requested date window, so it does not honour `modified_after` (WooCommerce before 5.8, or a proxy that drops the parameter). An app should show its own words for the `code`; the message, `This store needs WooCommerce 5.8 or later to sync products.`, is a plain fallback. The diagnostics are in `productId`, `bound` (the `modified_after` sent) and `received`. Today the pull retries a failed request every 5 s (RxDB's `retryTime`), one mark request each time. Stopping on errors that cannot recover is shared replication work.
39
41
  - `WooTillUpdateRequiredError` (`code: 'till_update_required'`, `fixedBy: 'till'`): the store answered 426, normally WCPOS's protocol gate (`serverCode: 'wcpos_update_required'`), so this till must be updated; the pull pauses until `resume()`.
40
42
  - `WooMissingUuidError`: a product came back without a `uuid`, so the store is not running the WCPOS Free plugin 1.10.0 or later, or the connector is not reaching it through `wcpos/v2`.
package/dist/index.d.ts CHANGED
@@ -69,6 +69,16 @@ declare class WooDateFilterError extends Error {
69
69
  readonly minVersion = "5.8";
70
70
  constructor(productId: number | undefined, bound: string, received: string | undefined);
71
71
  }
72
+ /** A stale JWT plugin 403 on WCPOS 1.10.0–1.10.7 (wcpos/woocommerce-pos#1863; fixed by 7243675c9). */
73
+ declare class WooPluginUpdateRequiredError extends Error {
74
+ readonly storeCode: string;
75
+ name: string;
76
+ readonly code: "unsupported_store";
77
+ readonly fixedBy: "store";
78
+ readonly software = "WCPOS";
79
+ readonly minVersion = "1.10.8";
80
+ constructor(storeCode: string);
81
+ }
72
82
  /**
73
83
  * Replication adapter for WooCommerce products.
74
84
  *
@@ -88,9 +98,9 @@ declare const wooReconcileFingerprint: (p: {
88
98
  stock_status?: string | null;
89
99
  }) => string;
90
100
  /**
91
- * The daily catalogue check for WooCommerce (#248): lists every published product with its fingerprint,
92
- * page by page with no date filter, and hands differences to `feed`, which reaches the collection only
93
- * through the pull. `confirmGone` is the deletion proof: a by-id re-read.
101
+ * The daily catalogue check for WooCommerce (#248): lists every published product with its fingerprint, in one
102
+ * fast-path request where the store offers it (#313), else page by page, with no date filter, and hands differences
103
+ * to `feed`, which reaches the collection only through the pull. `confirmGone` is the deletion proof: a by-id re-read.
94
104
  */
95
105
  declare function wooCatalogueReconcile(feed: Pick<ReconcileFeed<any>, 'enqueue'>): CatalogueReconcileAdapter<any, number>;
96
106
 
@@ -124,4 +134,4 @@ declare function createWooCommerceConnector(): TallyConnector;
124
134
  */
125
135
  declare const woocommerceConnector: TallyConnector;
126
136
 
127
- export { WooDateFilterError, WooMissingTokenError, WooMissingUuidError, WooTillUpdateRequiredError, createWooCommerceConnector, wcposClientPart, wooCatalogueReconcile, wooProductReplication, wooProductSchema, wooProductSync, wooProductTraits, wooReconcileFingerprint, woocommerceConnector };
137
+ export { WooDateFilterError, WooMissingTokenError, WooMissingUuidError, WooPluginUpdateRequiredError, WooTillUpdateRequiredError, createWooCommerceConnector, wcposClientPart, wooCatalogueReconcile, wooProductReplication, wooProductSchema, wooProductSync, wooProductTraits, wooReconcileFingerprint, woocommerceConnector };
package/dist/index.js CHANGED
@@ -259,6 +259,8 @@ var MAX_STORE_MESSAGE = 200;
259
259
  async function checkResponse(response) {
260
260
  if (response.ok) return;
261
261
  if (response.status === 401 || response.status === 403) {
262
+ const body = response.status === 403 ? await response.json().catch(() => void 0) : void 0;
263
+ if (typeof body?.code === "string" && body.code.startsWith("jwt_auth_")) throw new WooPluginUpdateRequiredError(body.code);
262
264
  throw new ConnectorUnauthorizedError(`WooCommerce API error: ${response.status}`, response.status);
263
265
  }
264
266
  if (response.status === 426) {
@@ -313,6 +315,17 @@ var WooDateFilterError = class extends Error {
313
315
  /** The first WooCommerce version that applies `modified_after` (5.8). */
314
316
  minVersion = "5.8";
315
317
  };
318
+ var WooPluginUpdateRequiredError = class extends Error {
319
+ constructor(storeCode) {
320
+ super("This store needs WCPOS 1.10.8 or later: the JWT Authentication plugin blocks the till on older versions.");
321
+ this.storeCode = storeCode;
322
+ }
323
+ name = "WooPluginUpdateRequiredError";
324
+ code = "unsupported_store";
325
+ fixedBy = "store";
326
+ software = "WCPOS";
327
+ minVersion = "1.10.8";
328
+ };
316
329
  var wooProductReplication = {
317
330
  pull: {
318
331
  handler: async function pull(lastCheckpoint, batchSize, context, requests = 0) {
@@ -380,7 +393,7 @@ var wooProductReplication = {
380
393
  // src/reconcile/catalogue.ts
381
394
  import { errorKind } from "@tallyui/core";
382
395
  var PAGE_SIZE = 100;
383
- var LISTING_FIELDS = "id,uuid,date_modified_gmt,stock_quantity,stock_status";
396
+ var LISTING_FIELDS = "id,date_modified_gmt,stock_quantity,stock_status";
384
397
  var ID_FAST_PATH = "products_id_fast_path";
385
398
  var wooReconcileFingerprint = (p) => [p.date_modified_gmt ?? "", p.stock_quantity ?? "", p.stock_status ?? ""].join("|");
386
399
  async function get(path, context) {
@@ -401,12 +414,30 @@ async function wooHasIdFastPath(context) {
401
414
  }
402
415
  return Array.isArray(status?.capabilities) && status.capabilities.includes(ID_FAST_PATH);
403
416
  }
417
+ function wooBulkListingUrl(baseUrl) {
418
+ return `${baseUrl}/products?${new URLSearchParams({ per_page: "-1", _fields: LISTING_FIELDS })}`;
419
+ }
420
+ var toEntries = (rows) => rows.filter((row) => Number.isInteger(row?.id)).map((row) => ({ key: String(row.id), fingerprint: wooReconcileFingerprint(row), remote: row.id }));
404
421
  function wooCatalogueReconcile(feed) {
405
422
  return {
406
423
  async *fetchPages(context, from = 1) {
407
424
  const fast = await wooHasIdFastPath(context);
408
425
  yield { entries: [], cursor: from };
409
426
  if (fast) {
427
+ let rows;
428
+ let failed = false;
429
+ try {
430
+ rows = await get(wooBulkListingUrl(""), context);
431
+ } catch (error) {
432
+ if (errorKind(error) === "till" || context.signal?.aborted) throw error;
433
+ failed = true;
434
+ console.warn(`WooCommerce catalogue reconcile: the fast path failed (${error?.message ?? error}); listing page by page.`);
435
+ }
436
+ if (Array.isArray(rows)) {
437
+ yield { entries: toEntries(rows), cursor: from };
438
+ return;
439
+ }
440
+ if (!failed) console.warn("WooCommerce catalogue reconcile: the fast path answered with something that is not a list; listing page by page.");
410
441
  }
411
442
  for (let page = from; ; page++) {
412
443
  const params = new URLSearchParams({
@@ -418,15 +449,13 @@ function wooCatalogueReconcile(feed) {
418
449
  _fields: LISTING_FIELDS
419
450
  });
420
451
  const rows = await get(`/products?${params}`, context);
421
- const entries = rows.map((row) => {
422
- if (typeof row.uuid !== "string" || row.uuid.length === 0) throw new WooMissingUuidError(row.id);
423
- return { key: row.uuid, fingerprint: wooReconcileFingerprint(row), remote: row.id };
424
- });
425
- yield { entries, cursor: page + 1 };
452
+ yield { entries: toEntries(rows), cursor: page + 1 };
426
453
  if (rows.length < PAGE_SIZE) return;
427
454
  }
428
455
  },
429
456
  fingerprint: wooReconcileFingerprint,
457
+ // Both listings key on the numeric id, never the till-local uuid (#313).
458
+ matchKey: (doc) => Number.isInteger(doc.id) ? String(doc.id) : void 0,
430
459
  async confirmGone(locals, context) {
431
460
  const ids = locals.map((doc) => doc.id).filter((id) => Number.isInteger(id));
432
461
  if (!ids.length) return [];
@@ -460,11 +489,11 @@ async function wooFetchByIds(entries, context) {
460
489
  return documents;
461
490
  }
462
491
  function createWooReconcileFeed() {
463
- return createReconcileFeed({ key: (doc) => doc.uuid, fetchByIds: wooFetchByIds });
492
+ return createReconcileFeed({ key: (doc) => doc.uuid, remote: (doc) => doc.id, fetchByIds: wooFetchByIds });
464
493
  }
465
494
 
466
495
  // package.json
467
- var version = "3.0.0-next.0";
496
+ var version = "3.0.0-next.2";
468
497
 
469
498
  // src/index.ts
470
499
  import { ConnectorUnauthorizedError as ConnectorUnauthorizedError3 } from "@tallyui/core";
@@ -473,7 +502,7 @@ function wcposClientPart(part) {
473
502
  }
474
503
  var WooMissingTokenError = class extends ConnectorUnauthorizedError2 {
475
504
  constructor() {
476
- super("WooCommerce credentials have no WCPOS access token: sign in again");
505
+ super("WooCommerce credentials have no WCPOS access token: sign in again", 401);
477
506
  this.name = "WooMissingTokenError";
478
507
  }
479
508
  };
@@ -482,9 +511,10 @@ function createWooCommerceConnector() {
482
511
  return {
483
512
  ...wooConnectorParts,
484
513
  replication: {
485
- // One replication per collection; the reconcile feed is last, so its fetch wins duplicates. legacyKey
514
+ // One replication per collection; the reconcile feed is last, so its fetch wins duplicates;
515
+ // duplicates are matched by uuid, the primary key, not the store id (#331). legacyKey
486
516
  // reads an existing install's plain pull checkpoint as the product feed's, so it does not resync.
487
- products: combinePullAdapters({ products: wooProductReplication, reconcile: catalogueFeed.adapter }, { legacyKey: "products" })
517
+ products: combinePullAdapters({ products: wooProductReplication, reconcile: catalogueFeed.adapter }, { legacyKey: "products", key: (doc) => doc.uuid })
488
518
  },
489
519
  reconcile: {
490
520
  // The feed's fetchByIds asks for at most this many ids per request; the runner budgets by requests.
@@ -545,6 +575,7 @@ export {
545
575
  WooDateFilterError,
546
576
  WooMissingTokenError,
547
577
  WooMissingUuidError,
578
+ WooPluginUpdateRequiredError,
548
579
  WooTillUpdateRequiredError,
549
580
  createWooCommerceConnector,
550
581
  wcposClientPart,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/connector-woocommerce",
3
- "version": "3.0.0-next.0",
3
+ "version": "3.0.0-next.2",
4
4
  "type": "module",
5
5
  "description": "WooCommerce connector for Tally UI",
6
6
  "main": "./dist/index.js",
@@ -26,10 +26,10 @@
26
26
  "directory": "connectors/woocommerce"
27
27
  },
28
28
  "peerDependencies": {
29
- "@tallyui/core": "^3.0.0-next.0"
29
+ "@tallyui/core": "^3.0.0-next.2"
30
30
  },
31
31
  "devDependencies": {
32
- "@tallyui/database": "3.0.0-next.0",
32
+ "@tallyui/database": "3.0.0-next.2",
33
33
  "rxdb": "17.5.0"
34
34
  },
35
35
  "scripts": {
package/src/index.ts CHANGED
@@ -15,7 +15,7 @@ export function wcposClientPart(part: string): string {
15
15
 
16
16
  export class WooMissingTokenError extends ConnectorUnauthorizedError {
17
17
  constructor() {
18
- super('WooCommerce credentials have no WCPOS access token: sign in again');
18
+ super('WooCommerce credentials have no WCPOS access token: sign in again', 401);
19
19
  this.name = 'WooMissingTokenError';
20
20
  }
21
21
  }
@@ -44,9 +44,10 @@ export function createWooCommerceConnector(): TallyConnector {
44
44
  return {
45
45
  ...wooConnectorParts,
46
46
  replication: {
47
- // One replication per collection; the reconcile feed is last, so its fetch wins duplicates. legacyKey
47
+ // One replication per collection; the reconcile feed is last, so its fetch wins duplicates;
48
+ // duplicates are matched by uuid, the primary key, not the store id (#331). legacyKey
48
49
  // reads an existing install's plain pull checkpoint as the product feed's, so it does not resync.
49
- products: combinePullAdapters({ products: wooProductReplication, reconcile: catalogueFeed.adapter }, { legacyKey: 'products' }),
50
+ products: combinePullAdapters({ products: wooProductReplication, reconcile: catalogueFeed.adapter }, { legacyKey: 'products', key: (doc: any) => doc.uuid }),
50
51
  },
51
52
  reconcile: {
52
53
  // The feed's fetchByIds asks for at most this many ids per request; the runner budgets by requests.
@@ -118,5 +119,5 @@ export { ConnectorUnauthorizedError } from '@tallyui/core';
118
119
  export { wooProductSchema } from './schemas/products';
119
120
  export { wooProductTraits } from './traits/product';
120
121
  export { wooProductSync } from './sync/products';
121
- export { wooProductReplication, WooDateFilterError, WooMissingUuidError, WooTillUpdateRequiredError } from './replication/products';
122
+ export { wooProductReplication, WooDateFilterError, WooPluginUpdateRequiredError, WooMissingUuidError, WooTillUpdateRequiredError } from './replication/products';
122
123
  export { wooCatalogueReconcile, wooReconcileFingerprint } from './reconcile/catalogue';
@@ -1,10 +1,13 @@
1
1
  import { errorKind, type CatalogueReconcileAdapter, type CatalogueReconcileEntry, type ReconcileFeed, type SyncContext } from '@tallyui/core';
2
- import { checkResponse, WooMissingUuidError } from '../replication/products';
2
+ import { checkResponse } from '../replication/products';
3
3
 
4
4
  /** The listing's page size, WordPress's per_page maximum. */
5
5
  const PAGE_SIZE = 100;
6
- /** Enough to compare: the key, the numeric id to refetch by, and the fingerprint's fields. */
7
- const LISTING_FIELDS = 'id,uuid,date_modified_gmt,stock_quantity,stock_status';
6
+ /**
7
+ * Both listings' fields, and exactly the fast path's: the numeric id (the key, and how a product is refetched) and
8
+ * the fingerprint's. No uuid: the pull's `toProductDocument` still requires one on every product it delivers.
9
+ */
10
+ const LISTING_FIELDS = 'id,date_modified_gmt,stock_quantity,stock_status';
8
11
  /** The wcpos/v2/status capability that announces the bulk-id listing (wcpos/woocommerce-pos#2113). */
9
12
  const ID_FAST_PATH = 'products_id_fast_path';
10
13
 
@@ -27,8 +30,9 @@ async function get(path: string, context: SyncContext): Promise<any> {
27
30
  /**
28
31
  * The switch for the bulk-id fast path: true only when wcpos/v2/status lists `products_id_fast_path` in
29
32
  * `capabilities` (a missing field is false). Never a version number, and never a failed attempt.
30
- * A failed read (network, non-OK, non-JSON) is "no capability known", so the paged listing runs; only a
31
- * till-class error (401, 403, 426 through checkResponse) stops the pass, since the listing would fail the same way.
33
+ * A failed status read is "no capability known"; only a 401 or 403 would make the paged listing fail the same way.
34
+ * Only a till-class error (401 `unauthorized`, 426 `till_update_required`) or an abort stops the pass.
35
+ * A 403 `forbidden` is store-class: the catalogue reconcile skips this check and tries again at the next hourly check.
32
36
  */
33
37
  export async function wooHasIdFastPath(context: SyncContext): Promise<boolean> {
34
38
  let status: any;
@@ -42,19 +46,21 @@ export async function wooHasIdFastPath(context: SyncContext): Promise<boolean> {
42
46
  }
43
47
 
44
48
  /**
45
- * The fast path's one request, pinned to the contract recorded for wcpos/woocommerce-pos#2113: WCPOS's own
46
- * `fields`, not WordPress's `_fields`, answered from SQL. The context headers carry `X-WCPOS`.
49
+ * The fast path's one request (wcpos/woocommerce-pos #2116, #2119, #2121): WordPress's `_fields` with exactly these
50
+ * four, answered from SQL, published POS-visible products only. The context headers carry `X-WCPOS`.
47
51
  */
48
52
  export function wooBulkListingUrl(baseUrl: string): string {
49
- const params = new URLSearchParams({ per_page: '-1' });
50
- for (const field of ['id', 'date_modified_gmt', 'stock_quantity', 'stock_status']) params.append('fields[]', field);
51
- return `${baseUrl}/products?${params}`;
53
+ return `${baseUrl}/products?${new URLSearchParams({ per_page: '-1', _fields: LISTING_FIELDS })}`;
52
54
  }
53
55
 
56
+ /** Listing rows as entries keyed by the numeric id (#313); a row without an integer id cannot be asked about, and is dropped. */
57
+ const toEntries = (rows: any[]) => rows.filter((row) => Number.isInteger(row?.id))
58
+ .map((row): CatalogueReconcileEntry => ({ key: String(row.id), fingerprint: wooReconcileFingerprint(row), remote: row.id }));
59
+
54
60
  /**
55
- * The daily catalogue check for WooCommerce (#248): lists every published product with its fingerprint,
56
- * page by page with no date filter, and hands differences to `feed`, which reaches the collection only
57
- * through the pull. `confirmGone` is the deletion proof: a by-id re-read.
61
+ * The daily catalogue check for WooCommerce (#248): lists every published product with its fingerprint, in one
62
+ * fast-path request where the store offers it (#313), else page by page, with no date filter, and hands differences
63
+ * to `feed`, which reaches the collection only through the pull. `confirmGone` is the deletion proof: a by-id re-read.
58
64
  */
59
65
  export function wooCatalogueReconcile(feed: Pick<ReconcileFeed<any>, 'enqueue'>): CatalogueReconcileAdapter<any, number> {
60
66
  return {
@@ -63,9 +69,23 @@ export function wooCatalogueReconcile(feed: Pick<ReconcileFeed<any>, 'enqueue'>)
63
69
  const fast = await wooHasIdFastPath(context);
64
70
  yield { entries: [], cursor: from };
65
71
  if (fast) {
66
- // TODO(#2113): list with one wooBulkListingUrl request. It returns `id` but no `uuid`, and entries are
67
- // keyed by uuid, so it needs an id-to-uuid mapping from the till's documents, which this adapter cannot
68
- // read. Until that is designed, a store with the capability is listed page by page like any other.
72
+ // The whole catalogue in one request, so `from` is moot. A store that refuses it (a store scope answers 400)
73
+ // falls back to the paged listing, which fails the same way. A 403 `forbidden` is skipped until the next hourly
74
+ // check; only a till-class error or an abort stops the pass.
75
+ let rows: any;
76
+ let failed = false;
77
+ try {
78
+ rows = await get(wooBulkListingUrl(''), context); // get() prefixes context.baseUrl
79
+ } catch (error) {
80
+ if (errorKind(error) === 'till' || context.signal?.aborted) throw error;
81
+ failed = true;
82
+ console.warn(`WooCommerce catalogue reconcile: the fast path failed (${(error as Error)?.message ?? error}); listing page by page.`);
83
+ }
84
+ if (Array.isArray(rows)) {
85
+ yield { entries: toEntries(rows), cursor: from };
86
+ return;
87
+ }
88
+ if (!failed) console.warn('WooCommerce catalogue reconcile: the fast path answered with something that is not a list; listing page by page.');
69
89
  }
70
90
  // status=publish: a product that is not published is absent, so it becomes a deletion candidate and
71
91
  // confirmGone decides; drafts cost no daily refetch.
@@ -74,15 +94,13 @@ export function wooCatalogueReconcile(feed: Pick<ReconcileFeed<any>, 'enqueue'>)
74
94
  per_page: String(PAGE_SIZE), page: String(page), orderby: 'id', order: 'asc', status: 'publish', _fields: LISTING_FIELDS,
75
95
  });
76
96
  const rows: any[] = await get(`/products?${params}`, context);
77
- const entries = rows.map((row): CatalogueReconcileEntry => {
78
- if (typeof row.uuid !== 'string' || row.uuid.length === 0) throw new WooMissingUuidError(row.id);
79
- return { key: row.uuid, fingerprint: wooReconcileFingerprint(row), remote: row.id };
80
- });
81
- yield { entries, cursor: page + 1 };
97
+ yield { entries: toEntries(rows), cursor: page + 1 };
82
98
  if (rows.length < PAGE_SIZE) return;
83
99
  }
84
100
  },
85
101
  fingerprint: wooReconcileFingerprint,
102
+ // Both listings key on the numeric id, never the till-local uuid (#313).
103
+ matchKey: (doc) => (Number.isInteger(doc.id) ? String(doc.id) : undefined),
86
104
  async confirmGone(locals, context) {
87
105
  const ids = locals.map((doc) => doc.id).filter((id) => Number.isInteger(id));
88
106
  // An empty include= would list the whole store; a local without a numeric id has no proof and is kept.
@@ -26,9 +26,10 @@ export async function wooFetchByIds(entries: Array<ReconcileFetchEntry<any>>, co
26
26
  }
27
27
 
28
28
  /**
29
- * The reconcile feed for `products`, keyed by the primary key (`uuid`): the numeric `id` is only how the
30
- * store is asked (#248 design note, §6). Its corrections reach the collection only through the pull.
29
+ * The reconcile feed for `products`, keyed by the primary key (`uuid`): the numeric `id` is how the store is
30
+ * asked (#248 design note, §6), and how a listed product the till lacks, queued under its id, is matched (#313).
31
+ * Its corrections reach the collection only through the pull.
31
32
  */
32
33
  export function createWooReconcileFeed(): ReconcileFeed<any> {
33
- return createReconcileFeed<any>({ key: (doc) => doc.uuid, fetchByIds: wooFetchByIds });
34
+ return createReconcileFeed<any>({ key: (doc) => doc.uuid, remote: (doc) => doc.id, fetchByIds: wooFetchByIds });
34
35
  }
@@ -21,7 +21,10 @@ const MAX_STORE_MESSAGE = 200;
21
21
  export async function checkResponse(response: Response) {
22
22
  if (response.ok) return;
23
23
  if (response.status === 401 || response.status === 403) {
24
- throw new ConnectorUnauthorizedError(`WooCommerce API error: ${response.status}`, response.status);
24
+ // Older WCPOS versions leak the JWT plugin's stale 403 despite a valid token.
25
+ const body = response.status === 403 ? await response.json().catch(() => undefined) : undefined;
26
+ if (typeof body?.code === 'string' && body.code.startsWith('jwt_auth_')) throw new WooPluginUpdateRequiredError(body.code);
27
+ throw new ConnectorUnauthorizedError(`WooCommerce API error: ${response.status}`, response.status as 401 | 403);
25
28
  }
26
29
  if (response.status === 426) {
27
30
  // Only the plugin's own gate means the till needs updating; any other 426 is retried as transient, with the store's message.
@@ -87,6 +90,19 @@ export class WooDateFilterError extends Error {
87
90
  }
88
91
  }
89
92
 
93
+ /** A stale JWT plugin 403 on WCPOS 1.10.0–1.10.7 (wcpos/woocommerce-pos#1863; fixed by 7243675c9). */
94
+ export class WooPluginUpdateRequiredError extends Error {
95
+ name = 'WooPluginUpdateRequiredError';
96
+ readonly code = 'unsupported_store' as const;
97
+ readonly fixedBy = 'store' as const;
98
+ readonly software = 'WCPOS';
99
+ readonly minVersion = '1.10.8';
100
+
101
+ constructor(readonly storeCode: string) {
102
+ super('This store needs WCPOS 1.10.8 or later: the JWT Authentication plugin blocks the till on older versions.');
103
+ }
104
+ }
105
+
90
106
  /**
91
107
  * Replication adapter for WooCommerce products.
92
108
  *