@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 +3 -1
- package/dist/index.d.ts +11 -6
- package/dist/index.js +134 -117
- package/package.json +3 -3
- package/src/index.ts +1 -1
- package/src/reconcile/catalogue.ts +10 -6
- package/src/replication/products.ts +39 -11
- package/src/traits/product.ts +2 -1
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
|
-
- `
|
|
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
|
-
/**
|
|
73
|
-
|
|
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: "
|
|
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
|
-
|
|
79
|
-
readonly
|
|
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,
|
|
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
|
|
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
|
-
|
|
300
|
-
|
|
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
|
|
228
|
+
var WooTokenRefusedError = class extends Error {
|
|
319
229
|
constructor(storeCode) {
|
|
320
|
-
super("
|
|
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 = "
|
|
324
|
-
code = "
|
|
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
|
-
|
|
327
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
29
|
+
"@tallyui/core": "^3.0.1"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@tallyui/database": "3.0.
|
|
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,
|
|
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
|
|
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
|
|
111
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
readonly
|
|
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(
|
|
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
|
|
package/src/traits/product.ts
CHANGED
|
@@ -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
|
|
73
|
+
getBarcode: (doc) => wooProductBarcode(doc),
|
|
73
74
|
|
|
74
75
|
getCategoryNames: (doc) =>
|
|
75
76
|
(doc.categories ?? []).map((cat: any) => cat.name).filter(Boolean),
|