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

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
@@ -13,6 +13,8 @@ It also sends `X-WCPOS-Protocol: 2`, which WCPOS 2.0 requires of POS requests, a
13
13
 
14
14
  ## Product pull
15
15
 
16
+ On WCPOS 1.10.x, the product uuid comes from the `_woocommerce_pos_uuid` meta and the barcode from `global_unique_id`. Non-empty top-level `uuid` / `barcode` fields (WCPOS 2.0) win when present.
17
+
16
18
  The pull works in passes:
17
19
  - a small request finds the store's newest `date_modified_gmt`;
18
20
  - the pass then pages every product modified since the last pass, by id with an offset;
@@ -36,7 +38,7 @@ The incremental pull can miss an edit: a `modified_after` that over-excludes (in
36
38
  ## Errors
37
39
 
38
40
  - `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.
41
+ - `WooTokenRefusedError` (`code: 'store_misconfigured'`, `fixedBy: 'store'`, `fix`, and the store's own `storeCode`): the store answered a `jwt_auth_*` 403, the JWT Authentication plugin refusing the till's token. WCPOS 1.10.0–1.10.7 sent it for a valid token (wcpos#1863), and later versions can still send it when the token resolves to another user, so the error names no version. The store owner checks the JWT Authentication plugin's settings, or the till is paired again; the pull retries on the store schedule and the till stays signed in.
40
42
  - `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.
41
43
  - `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()`.
42
44
  - `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,14 +69,19 @@ 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 {
72
+ /**
73
+ * A `jwt_auth_*` 403: the store's JWT Authentication plugin refused the till's token. WCPOS 1.10.0–1.10.7 sent it for a valid token
74
+ * (wcpos/woocommerce-pos#1863); 1.10.8 and later can still send it when the token resolves to another user. So the
75
+ * message names no version: a version hint belongs only where a response itself carries the WCPOS version (#360).
76
+ */
77
+ declare class WooTokenRefusedError extends Error {
74
78
  readonly storeCode: string;
75
79
  name: string;
76
- readonly code: "unsupported_store";
80
+ readonly code: "store_misconfigured";
81
+ /** The store owner fixes it on the store, so the pull waits the store delay and the till stays signed in (`errorKind`). */
77
82
  readonly fixedBy: "store";
78
- readonly software = "WCPOS";
79
- readonly minVersion = "1.10.8";
83
+ /** The store-side remedy, for `SyncStatus`'s detail. */
84
+ readonly fix = "check the JWT Authentication plugin's settings";
80
85
  constructor(storeCode: string);
81
86
  }
82
87
  /**
@@ -134,4 +139,4 @@ declare function createWooCommerceConnector(): TallyConnector;
134
139
  */
135
140
  declare const woocommerceConnector: TallyConnector;
136
141
 
137
- export { WooDateFilterError, WooMissingTokenError, WooMissingUuidError, WooPluginUpdateRequiredError, WooTillUpdateRequiredError, createWooCommerceConnector, wcposClientPart, wooCatalogueReconcile, wooProductReplication, wooProductSchema, wooProductSync, wooProductTraits, wooReconcileFingerprint, woocommerceConnector };
142
+ export { WooDateFilterError, WooMissingTokenError, WooMissingUuidError, WooTillUpdateRequiredError, WooTokenRefusedError, createWooCommerceConnector, wcposClientPart, wooCatalogueReconcile, wooProductReplication, wooProductSchema, wooProductSync, wooProductTraits, wooReconcileFingerprint, woocommerceConnector };
package/dist/index.js CHANGED
@@ -148,109 +148,6 @@ var wooProductSchema = {
148
148
 
149
149
  // src/traits/product.ts
150
150
  import { moneyFromMajor } from "@tallyui/core";
151
- var WOO_STOCK_STATUS = {
152
- instock: "in_stock",
153
- outofstock: "out_of_stock",
154
- onbackorder: "backorder"
155
- };
156
- var wooProductTraits = {
157
- getId: (doc) => String(doc.id),
158
- getName: (doc) => doc.name ?? "",
159
- getSku: (doc) => doc.sku || void 0,
160
- getPrices: (doc, context) => {
161
- const currency = context?.currency ?? "XXX";
162
- const prices = [];
163
- const base = moneyFromMajor(doc.regular_price || doc.price, currency);
164
- if (base) prices.push({ ...base, kind: "base" });
165
- const sale = doc.on_sale === true ? moneyFromMajor(doc.sale_price, currency) : void 0;
166
- if (sale) prices.push({ ...sale, kind: "sale" });
167
- return prices;
168
- },
169
- getStock: (doc) => ({
170
- status: WOO_STOCK_STATUS[doc.stock_status] ?? "unknown",
171
- quantity: doc.manage_stock === false ? void 0 : doc.stock_quantity ?? void 0
172
- }),
173
- getPrice: (doc) => doc.price || void 0,
174
- getRegularPrice: (doc) => doc.regular_price || void 0,
175
- getSalePrice: (doc) => doc.sale_price || void 0,
176
- isOnSale: (doc) => doc.on_sale === true,
177
- getImageUrl: (doc) => doc.images?.[0]?.src || void 0,
178
- getImageUrls: (doc) => (doc.images ?? []).map((img) => img.src).filter(Boolean),
179
- getDescription: (doc) => doc.short_description || doc.description || void 0,
180
- getStockStatus: (doc) => {
181
- const status = doc.stock_status;
182
- if (status === "instock" || status === "outofstock" || status === "onbackorder") {
183
- return status;
184
- }
185
- return "unknown";
186
- },
187
- getStockQuantity: (doc) => doc.stock_quantity ?? null,
188
- hasVariants: (doc) => doc.type === "variable",
189
- isSellable: (doc) => doc.status === void 0 || doc.status === "publish",
190
- getVariantCount: (doc) => doc.type === "variable" ? doc.variations?.length ?? 0 : 1,
191
- getType: (doc) => doc.type ?? "simple",
192
- getBarcode: (doc) => doc.barcode || void 0,
193
- getCategoryNames: (doc) => (doc.categories ?? []).map((cat) => cat.name).filter(Boolean)
194
- };
195
-
196
- // src/sync/products.ts
197
- var wooProductSync = {
198
- async fetchAllIds(context) {
199
- const response = await fetch(`${context.baseUrl}/products`, {
200
- headers: {
201
- ...context.headers,
202
- "Content-Type": "application/json"
203
- },
204
- signal: context.signal
205
- });
206
- if (!response.ok) {
207
- throw new Error(`WooCommerce API error: ${response.status}`);
208
- }
209
- const products = await response.json();
210
- return products.map((p) => ({
211
- id: String(p.id),
212
- dateModified: p.date_modified_gmt
213
- }));
214
- },
215
- async fetchByIds(ids, context) {
216
- const include = ids.join(",");
217
- const response = await fetch(
218
- `${context.baseUrl}/products?include=${include}&per_page=${ids.length}`,
219
- {
220
- headers: {
221
- ...context.headers,
222
- "Content-Type": "application/json"
223
- },
224
- signal: context.signal
225
- }
226
- );
227
- if (!response.ok) {
228
- throw new Error(`WooCommerce API error: ${response.status}`);
229
- }
230
- return response.json();
231
- },
232
- async fetchModifiedAfter(date, context) {
233
- const params = new URLSearchParams({
234
- modified_after: date,
235
- dates_are_gmt: "true",
236
- per_page: "100"
237
- });
238
- const response = await fetch(
239
- `${context.baseUrl}/products?${params}`,
240
- {
241
- headers: {
242
- ...context.headers,
243
- "Content-Type": "application/json"
244
- },
245
- signal: context.signal
246
- }
247
- );
248
- if (!response.ok) {
249
- throw new Error(`WooCommerce API error: ${response.status}`);
250
- }
251
- return response.json();
252
- }
253
- };
254
151
 
255
152
  // src/replication/products.ts
256
153
  import { ConnectorUnauthorizedError } from "@tallyui/core";
@@ -260,7 +157,7 @@ async function checkResponse(response) {
260
157
  if (response.ok) return;
261
158
  if (response.status === 401 || response.status === 403) {
262
159
  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);
160
+ if (typeof body?.code === "string" && body.code.startsWith("jwt_auth_")) throw new WooTokenRefusedError(body.code);
264
161
  throw new ConnectorUnauthorizedError(`WooCommerce API error: ${response.status}`, response.status);
265
162
  }
266
163
  if (response.status === 426) {
@@ -295,9 +192,22 @@ var WooMissingUuidError = class extends Error {
295
192
  this.productId = id;
296
193
  }
297
194
  };
195
+ var WCPOS_UUID_META_KEY = "_woocommerce_pos_uuid";
196
+ function wooProductUuid(product) {
197
+ if (typeof product.uuid === "string" && product.uuid.length > 0) return product.uuid;
198
+ return product.meta_data?.find((entry) => entry.key === WCPOS_UUID_META_KEY && typeof entry.value === "string" && entry.value.length > 0)?.value;
199
+ }
200
+ function wooProductBarcode(product) {
201
+ if (typeof product.barcode === "string" && product.barcode.length > 0) return product.barcode;
202
+ if (typeof product.global_unique_id === "string" && product.global_unique_id.length > 0) return product.global_unique_id;
203
+ return void 0;
204
+ }
298
205
  function toProductDocument(product) {
299
- if (typeof product.uuid !== "string" || product.uuid.length === 0) throw new WooMissingUuidError(product.id);
300
- return { ...product, _deleted: product.status !== "publish" };
206
+ const uuid = wooProductUuid(product);
207
+ if (uuid === void 0) throw new WooMissingUuidError(product.id);
208
+ const barcode = wooProductBarcode(product);
209
+ const projected = Object.fromEntries(Object.keys(wooProductSchema.properties).filter((key) => Object.prototype.hasOwnProperty.call(product, key)).map((key) => [key, product[key]]));
210
+ return { ...projected, uuid, ...barcode !== void 0 ? { barcode } : {}, _deleted: product.status !== "publish" };
301
211
  }
302
212
  var WooDateFilterError = class extends Error {
303
213
  constructor(productId, bound, received) {
@@ -315,16 +225,17 @@ var WooDateFilterError = class extends Error {
315
225
  /** The first WooCommerce version that applies `modified_after` (5.8). */
316
226
  minVersion = "5.8";
317
227
  };
318
- var WooPluginUpdateRequiredError = class extends Error {
228
+ var WooTokenRefusedError = class extends Error {
319
229
  constructor(storeCode) {
320
- super("This store needs WCPOS 1.10.8 or later: the JWT Authentication plugin blocks the till on older versions.");
230
+ super("The store refused the sign-in token (403). Ask the store owner to check the JWT Authentication plugin's settings, or pair the till again.");
321
231
  this.storeCode = storeCode;
322
232
  }
323
- name = "WooPluginUpdateRequiredError";
324
- code = "unsupported_store";
233
+ name = "WooTokenRefusedError";
234
+ code = "store_misconfigured";
235
+ /** The store owner fixes it on the store, so the pull waits the store delay and the till stays signed in (`errorKind`). */
325
236
  fixedBy = "store";
326
- software = "WCPOS";
327
- minVersion = "1.10.8";
237
+ /** The store-side remedy, for `SyncStatus`'s detail. */
238
+ fix = "check the JWT Authentication plugin's settings";
328
239
  };
329
240
  var wooProductReplication = {
330
241
  pull: {
@@ -390,6 +301,111 @@ var wooProductReplication = {
390
301
  }
391
302
  };
392
303
 
304
+ // src/traits/product.ts
305
+ var WOO_STOCK_STATUS = {
306
+ instock: "in_stock",
307
+ outofstock: "out_of_stock",
308
+ onbackorder: "backorder"
309
+ };
310
+ var wooProductTraits = {
311
+ getId: (doc) => String(doc.id),
312
+ getName: (doc) => doc.name ?? "",
313
+ getSku: (doc) => doc.sku || void 0,
314
+ getPrices: (doc, context) => {
315
+ const currency = context?.currency ?? "XXX";
316
+ const prices = [];
317
+ const base = moneyFromMajor(doc.regular_price || doc.price, currency);
318
+ if (base) prices.push({ ...base, kind: "base" });
319
+ const sale = doc.on_sale === true ? moneyFromMajor(doc.sale_price, currency) : void 0;
320
+ if (sale) prices.push({ ...sale, kind: "sale" });
321
+ return prices;
322
+ },
323
+ getStock: (doc) => ({
324
+ status: WOO_STOCK_STATUS[doc.stock_status] ?? "unknown",
325
+ quantity: doc.manage_stock === false ? void 0 : doc.stock_quantity ?? void 0
326
+ }),
327
+ getPrice: (doc) => doc.price || void 0,
328
+ getRegularPrice: (doc) => doc.regular_price || void 0,
329
+ getSalePrice: (doc) => doc.sale_price || void 0,
330
+ isOnSale: (doc) => doc.on_sale === true,
331
+ getImageUrl: (doc) => doc.images?.[0]?.src || void 0,
332
+ getImageUrls: (doc) => (doc.images ?? []).map((img) => img.src).filter(Boolean),
333
+ getDescription: (doc) => doc.short_description || doc.description || void 0,
334
+ getStockStatus: (doc) => {
335
+ const status = doc.stock_status;
336
+ if (status === "instock" || status === "outofstock" || status === "onbackorder") {
337
+ return status;
338
+ }
339
+ return "unknown";
340
+ },
341
+ getStockQuantity: (doc) => doc.stock_quantity ?? null,
342
+ hasVariants: (doc) => doc.type === "variable",
343
+ isSellable: (doc) => doc.status === void 0 || doc.status === "publish",
344
+ getVariantCount: (doc) => doc.type === "variable" ? doc.variations?.length ?? 0 : 1,
345
+ getType: (doc) => doc.type ?? "simple",
346
+ getBarcode: (doc) => wooProductBarcode(doc),
347
+ getCategoryNames: (doc) => (doc.categories ?? []).map((cat) => cat.name).filter(Boolean)
348
+ };
349
+
350
+ // src/sync/products.ts
351
+ var wooProductSync = {
352
+ async fetchAllIds(context) {
353
+ const response = await fetch(`${context.baseUrl}/products`, {
354
+ headers: {
355
+ ...context.headers,
356
+ "Content-Type": "application/json"
357
+ },
358
+ signal: context.signal
359
+ });
360
+ if (!response.ok) {
361
+ throw new Error(`WooCommerce API error: ${response.status}`);
362
+ }
363
+ const products = await response.json();
364
+ return products.map((p) => ({
365
+ id: String(p.id),
366
+ dateModified: p.date_modified_gmt
367
+ }));
368
+ },
369
+ async fetchByIds(ids, context) {
370
+ const include = ids.join(",");
371
+ const response = await fetch(
372
+ `${context.baseUrl}/products?include=${include}&per_page=${ids.length}`,
373
+ {
374
+ headers: {
375
+ ...context.headers,
376
+ "Content-Type": "application/json"
377
+ },
378
+ signal: context.signal
379
+ }
380
+ );
381
+ if (!response.ok) {
382
+ throw new Error(`WooCommerce API error: ${response.status}`);
383
+ }
384
+ return response.json();
385
+ },
386
+ async fetchModifiedAfter(date, context) {
387
+ const params = new URLSearchParams({
388
+ modified_after: date,
389
+ dates_are_gmt: "true",
390
+ per_page: "100"
391
+ });
392
+ const response = await fetch(
393
+ `${context.baseUrl}/products?${params}`,
394
+ {
395
+ headers: {
396
+ ...context.headers,
397
+ "Content-Type": "application/json"
398
+ },
399
+ signal: context.signal
400
+ }
401
+ );
402
+ if (!response.ok) {
403
+ throw new Error(`WooCommerce API error: ${response.status}`);
404
+ }
405
+ return response.json();
406
+ }
407
+ };
408
+
393
409
  // src/reconcile/catalogue.ts
394
410
  import { errorKind } from "@tallyui/core";
395
411
  var PAGE_SIZE = 100;
@@ -438,6 +454,7 @@ function wooCatalogueReconcile(feed) {
438
454
  return;
439
455
  }
440
456
  if (!failed) console.warn("WooCommerce catalogue reconcile: the fast path answered with something that is not a list; listing page by page.");
457
+ yield { entries: [], cursor: from };
441
458
  }
442
459
  for (let page = from; ; page++) {
443
460
  const params = new URLSearchParams({
@@ -459,10 +476,10 @@ function wooCatalogueReconcile(feed) {
459
476
  async confirmGone(locals, context) {
460
477
  const ids = locals.map((doc) => doc.id).filter((id) => Number.isInteger(id));
461
478
  if (!ids.length) return [];
462
- const params = new URLSearchParams({ include: ids.join(","), per_page: String(PAGE_SIZE), status: "any", _fields: "id,uuid,status" });
479
+ const params = new URLSearchParams({ include: ids.join(","), per_page: String(PAGE_SIZE), status: "any", _fields: "id,uuid,status,meta_data" });
463
480
  const rows = await get(`/products?${params}`, context);
464
- const live = new Set(rows.filter((row) => row.status === "publish").map((row) => row.id));
465
- return locals.filter((doc) => Number.isInteger(doc.id) && !live.has(doc.id)).map((doc) => doc.uuid);
481
+ const live = new Map(rows.filter((row) => row.status === "publish").map((row) => [row.id, wooProductUuid(row)]));
482
+ return locals.filter((doc) => Number.isInteger(doc.id) && (!live.has(doc.id) || typeof live.get(doc.id) === "string" && live.get(doc.id) !== doc.uuid)).map((doc) => doc.uuid);
466
483
  },
467
484
  // An entry with no numeric id, locally or in the listing, is dropped: the store cannot be asked about it, so
468
485
  // the feed would find nothing and tombstone it without proof. (The schema requires only uuid.)
@@ -493,7 +510,7 @@ function createWooReconcileFeed() {
493
510
  }
494
511
 
495
512
  // package.json
496
- var version = "3.0.0-next.2";
513
+ var version = "3.0.1";
497
514
 
498
515
  // src/index.ts
499
516
  import { ConnectorUnauthorizedError as ConnectorUnauthorizedError3 } from "@tallyui/core";
@@ -575,8 +592,8 @@ export {
575
592
  WooDateFilterError,
576
593
  WooMissingTokenError,
577
594
  WooMissingUuidError,
578
- WooPluginUpdateRequiredError,
579
595
  WooTillUpdateRequiredError,
596
+ WooTokenRefusedError,
580
597
  createWooCommerceConnector,
581
598
  wcposClientPart,
582
599
  wooCatalogueReconcile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/connector-woocommerce",
3
- "version": "3.0.0-next.2",
3
+ "version": "3.0.1",
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.2"
29
+ "@tallyui/core": "^3.0.1"
30
30
  },
31
31
  "devDependencies": {
32
- "@tallyui/database": "3.0.0-next.2",
32
+ "@tallyui/database": "3.0.1",
33
33
  "rxdb": "17.5.0"
34
34
  },
35
35
  "scripts": {
package/src/index.ts CHANGED
@@ -119,5 +119,5 @@ export { ConnectorUnauthorizedError } from '@tallyui/core';
119
119
  export { wooProductSchema } from './schemas/products';
120
120
  export { wooProductTraits } from './traits/product';
121
121
  export { wooProductSync } from './sync/products';
122
- export { wooProductReplication, WooDateFilterError, WooPluginUpdateRequiredError, WooMissingUuidError, WooTillUpdateRequiredError } from './replication/products';
122
+ export { wooProductReplication, WooDateFilterError, WooTokenRefusedError, WooMissingUuidError, WooTillUpdateRequiredError } from './replication/products';
123
123
  export { wooCatalogueReconcile, wooReconcileFingerprint } from './reconcile/catalogue';
@@ -1,5 +1,5 @@
1
1
  import { errorKind, type CatalogueReconcileAdapter, type CatalogueReconcileEntry, type ReconcileFeed, type SyncContext } from '@tallyui/core';
2
- import { checkResponse } from '../replication/products';
2
+ import { checkResponse, wooProductUuid } from '../replication/products';
3
3
 
4
4
  /** The listing's page size, WordPress's per_page maximum. */
5
5
  const PAGE_SIZE = 100;
@@ -32,7 +32,7 @@ async function get(path: string, context: SyncContext): Promise<any> {
32
32
  * `capabilities` (a missing field is false). Never a version number, and never a failed attempt.
33
33
  * A failed status read is "no capability known"; only a 401 or 403 would make the paged listing fail the same way.
34
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.
35
+ * A 403 `forbidden` is store-class: the whole pass is logged `skipped` and runs again at the next check (hourly by default).
36
36
  */
37
37
  export async function wooHasIdFastPath(context: SyncContext): Promise<boolean> {
38
38
  let status: any;
@@ -86,6 +86,8 @@ export function wooCatalogueReconcile(feed: Pick<ReconcileFeed<any>, 'enqueue'>)
86
86
  return;
87
87
  }
88
88
  if (!failed) console.warn('WooCommerce catalogue reconcile: the fast path answered with something that is not a list; listing page by page.');
89
+ // An empty page, so the fallback's first request takes its own budget slot, as the status read does.
90
+ yield { entries: [], cursor: from };
89
91
  }
90
92
  // status=publish: a product that is not published is absent, so it becomes a deletion candidate and
91
93
  // confirmGone decides; drafts cost no daily refetch.
@@ -105,10 +107,12 @@ export function wooCatalogueReconcile(feed: Pick<ReconcileFeed<any>, 'enqueue'>)
105
107
  const ids = locals.map((doc) => doc.id).filter((id) => Number.isInteger(id));
106
108
  // An empty include= would list the whole store; a local without a numeric id has no proof and is kept.
107
109
  if (!ids.length) return [];
108
- const params = new URLSearchParams({ include: ids.join(','), per_page: String(PAGE_SIZE), status: 'any', _fields: 'id,uuid,status' });
109
- const rows: Array<{ id: number; status: string }> = await get(`/products?${params}`, context);
110
- const live = new Set(rows.filter((row) => row.status === 'publish').map((row) => row.id));
111
- return locals.filter((doc) => Number.isInteger(doc.id) && !live.has(doc.id)).map((doc) => doc.uuid);
110
+ const params = new URLSearchParams({ include: ids.join(','), per_page: String(PAGE_SIZE), status: 'any', _fields: 'id,uuid,status,meta_data' });
111
+ const rows: Array<{ id: number; uuid: string; status: string }> = await get(`/products?${params}`, context);
112
+ const live = new Map(rows.filter((row) => row.status === 'publish').map((row) => [row.id, wooProductUuid(row)]));
113
+ // A live id backs only the store's uuid; other local copies are gone (#369).
114
+ // A row without a uuid can't prove the local copy stale, so a live id keeps it (#375 review).
115
+ return locals.filter((doc) => Number.isInteger(doc.id) && (!live.has(doc.id) || (typeof live.get(doc.id) === 'string' && live.get(doc.id) !== doc.uuid))).map((doc) => doc.uuid);
112
116
  },
113
117
  // An entry with no numeric id, locally or in the listing, is dropped: the store cannot be asked about it, so
114
118
  // the feed would find nothing and tombstone it without proof. (The schema requires only uuid.)
@@ -1,4 +1,5 @@
1
1
  import { ConnectorUnauthorizedError, type ReplicationAdapter } from '@tallyui/core';
2
+ import { wooProductSchema } from '../schemas/products';
2
3
 
3
4
  export type WooProductCheckpoint = {
4
5
  /** Inclusive lower bound (date_modified_gmt) of the current pass; '' = the whole catalogue. */
@@ -21,9 +22,9 @@ const MAX_STORE_MESSAGE = 200;
21
22
  export async function checkResponse(response: Response) {
22
23
  if (response.ok) return;
23
24
  if (response.status === 401 || response.status === 403) {
24
- // Older WCPOS versions leak the JWT plugin's stale 403 despite a valid token.
25
+ // A jwt_auth_* 403 is the store's JWT layer refusing the token, on any WCPOS version (#360).
25
26
  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
+ if (typeof body?.code === 'string' && body.code.startsWith('jwt_auth_')) throw new WooTokenRefusedError(body.code);
27
28
  throw new ConnectorUnauthorizedError(`WooCommerce API error: ${response.status}`, response.status as 401 | 403);
28
29
  }
29
30
  if (response.status === 426) {
@@ -65,13 +66,35 @@ export class WooMissingUuidError extends Error {
65
66
  }
66
67
  }
67
68
 
69
+ /** WCPOS's own uuid meta key; 1.10.x sends the product uuid only here (Uuid_Handler.php:29). */
70
+ export const WCPOS_UUID_META_KEY = '_woocommerce_pos_uuid';
71
+
72
+ /** The product's uuid: top-level `uuid` (the `next` shape) wins, else the `_woocommerce_pos_uuid` meta (1.10.x). */
73
+ export function wooProductUuid(product: any): string | undefined {
74
+ if (typeof product.uuid === 'string' && product.uuid.length > 0) return product.uuid;
75
+ return product.meta_data?.find((entry: any) =>
76
+ entry.key === WCPOS_UUID_META_KEY && typeof entry.value === 'string' && entry.value.length > 0)?.value;
77
+ }
78
+
79
+ /** The product's barcode: top-level `barcode` wins, else `global_unique_id` (WCPOS's default barcode field), else undefined. */
80
+ export function wooProductBarcode(product: any): string | undefined {
81
+ if (typeof product.barcode === 'string' && product.barcode.length > 0) return product.barcode;
82
+ if (typeof product.global_unique_id === 'string' && product.global_unique_id.length > 0) return product.global_unique_id;
83
+ return undefined;
84
+ }
85
+
68
86
  /**
69
87
  * One product row as the till stores it, the rule the pull and the reconcile feed share: a uuid is
70
88
  * required (WooMissingUuidError), and a product that is not published arrives deleted (#229).
71
89
  */
72
90
  export function toProductDocument(product: any): any {
73
- if (typeof product.uuid !== 'string' || product.uuid.length === 0) throw new WooMissingUuidError(product.id);
74
- return { ...product, _deleted: product.status !== 'publish' };
91
+ const uuid = wooProductUuid(product);
92
+ if (uuid === undefined) throw new WooMissingUuidError(product.id);
93
+ const barcode = wooProductBarcode(product);
94
+ const projected = Object.fromEntries(Object.keys(wooProductSchema.properties)
95
+ .filter((key) => Object.prototype.hasOwnProperty.call(product, key))
96
+ .map((key) => [key, product[key]]));
97
+ return { ...projected, uuid, ...(barcode !== undefined ? { barcode } : {}), _deleted: product.status !== 'publish' };
75
98
  }
76
99
 
77
100
  /** The store returned a product outside a modified_after window, so it does not apply the filter. */
@@ -90,16 +113,21 @@ export class WooDateFilterError extends Error {
90
113
  }
91
114
  }
92
115
 
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;
116
+ /**
117
+ * A `jwt_auth_*` 403: the store's JWT Authentication plugin refused the till's token. WCPOS 1.10.0–1.10.7 sent it for a valid token
118
+ * (wcpos/woocommerce-pos#1863); 1.10.8 and later can still send it when the token resolves to another user. So the
119
+ * message names no version: a version hint belongs only where a response itself carries the WCPOS version (#360).
120
+ */
121
+ export class WooTokenRefusedError extends Error {
122
+ name = 'WooTokenRefusedError';
123
+ readonly code = 'store_misconfigured' as const;
124
+ /** The store owner fixes it on the store, so the pull waits the store delay and the till stays signed in (`errorKind`). */
97
125
  readonly fixedBy = 'store' as const;
98
- readonly software = 'WCPOS';
99
- readonly minVersion = '1.10.8';
126
+ /** The store-side remedy, for `SyncStatus`'s detail. */
127
+ readonly fix = "check the JWT Authentication plugin's settings";
100
128
 
101
129
  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.');
130
+ super("The store refused the sign-in token (403). Ask the store owner to check the JWT Authentication plugin's settings, or pair the till again.");
103
131
  }
104
132
  }
105
133
 
@@ -1,5 +1,6 @@
1
1
  import { moneyFromMajor } from '@tallyui/core';
2
2
  import type { ProductPrice, ProductTraits, StockStatus } from '@tallyui/core';
3
+ import { wooProductBarcode } from '../replication/products';
3
4
 
4
5
  /** WooCommerce's stock_status strings, mapped to the neutral enum. */
5
6
  const WOO_STOCK_STATUS: Record<string, StockStatus> = {
@@ -69,7 +70,7 @@ export const wooProductTraits: ProductTraits = {
69
70
 
70
71
  getType: (doc) => doc.type ?? 'simple',
71
72
 
72
- getBarcode: (doc) => doc.barcode || undefined,
73
+ getBarcode: (doc) => wooProductBarcode(doc),
73
74
 
74
75
  getCategoryNames: (doc) =>
75
76
  (doc.categories ?? []).map((cat: any) => cat.name).filter(Boolean),