@tallyui/connector-woocommerce 3.0.1 → 3.0.3
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/dist/index.d.ts +34 -2
- package/dist/index.js +217 -2
- package/package.json +3 -3
- package/src/commands/transport.ts +126 -0
- package/src/index.ts +1 -0
- package/src/replication/products.ts +34 -1
- package/src/schemas/products.ts +9 -1
- package/src/traits/product.ts +17 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,39 @@
|
|
|
1
|
-
import { ProductTraits, CollectionSync, ReplicationAdapter, ReconcileFeed, CatalogueReconcileAdapter, ConnectorUnauthorizedError, TallyConnector } from '@tallyui/core';
|
|
1
|
+
import { OrderCreateEnvelope, CommandResult, ProductTraits, CollectionSync, ReplicationAdapter, ReconcileFeed, CatalogueReconcileAdapter, ConnectorUnauthorizedError, TallyConnector } from '@tallyui/core';
|
|
2
2
|
export { ConnectorUnauthorizedError } from '@tallyui/core';
|
|
3
3
|
import { RxJsonSchema } from 'rxdb';
|
|
4
4
|
|
|
5
|
+
type WooTransportOutcome = {
|
|
6
|
+
kind: 'results';
|
|
7
|
+
results: CommandResult[];
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'unauthorized';
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'refused';
|
|
12
|
+
status: number;
|
|
13
|
+
reason: string;
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'retry';
|
|
16
|
+
reason: string;
|
|
17
|
+
retryAfterMs?: number;
|
|
18
|
+
};
|
|
19
|
+
interface WooCommandTransportOptions {
|
|
20
|
+
baseUrl: string;
|
|
21
|
+
getHeaders: () => Record<string, string> | Promise<Record<string, string>>;
|
|
22
|
+
fetch?: typeof fetch;
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
}
|
|
25
|
+
declare function toWooOrderPayload(envelope: OrderCreateEnvelope): {
|
|
26
|
+
payload: Record<string, unknown>;
|
|
27
|
+
} | {
|
|
28
|
+
error: {
|
|
29
|
+
code: string;
|
|
30
|
+
message: string;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
declare function createWooCommandTransport(options: WooCommandTransportOptions): {
|
|
34
|
+
send(batch: OrderCreateEnvelope[]): Promise<WooTransportOutcome>;
|
|
35
|
+
};
|
|
36
|
+
|
|
5
37
|
/**
|
|
6
38
|
* WooCommerce Product RxDB schema.
|
|
7
39
|
* Closely mirrors the WooCommerce REST API product shape.
|
|
@@ -139,4 +171,4 @@ declare function createWooCommerceConnector(): TallyConnector;
|
|
|
139
171
|
*/
|
|
140
172
|
declare const woocommerceConnector: TallyConnector;
|
|
141
173
|
|
|
142
|
-
export { WooDateFilterError, WooMissingTokenError, WooMissingUuidError, WooTillUpdateRequiredError, WooTokenRefusedError, createWooCommerceConnector, wcposClientPart, wooCatalogueReconcile, wooProductReplication, wooProductSchema, wooProductSync, wooProductTraits, wooReconcileFingerprint, woocommerceConnector };
|
|
174
|
+
export { type WooCommandTransportOptions, WooDateFilterError, WooMissingTokenError, WooMissingUuidError, WooTillUpdateRequiredError, WooTokenRefusedError, createWooCommandTransport, createWooCommerceConnector, toWooOrderPayload, wcposClientPart, wooCatalogueReconcile, wooProductReplication, wooProductSchema, wooProductSync, wooProductTraits, wooReconcileFingerprint, woocommerceConnector };
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { combinePullAdapters, ConnectorUnauthorizedError as ConnectorUnauthorize
|
|
|
4
4
|
// src/schemas/products.ts
|
|
5
5
|
var wooProductSchema = {
|
|
6
6
|
title: "WooCommerce Product",
|
|
7
|
-
version:
|
|
7
|
+
version: 1,
|
|
8
8
|
type: "object",
|
|
9
9
|
primaryKey: "uuid",
|
|
10
10
|
properties: {
|
|
@@ -127,6 +127,22 @@ var wooProductSchema = {
|
|
|
127
127
|
type: "array",
|
|
128
128
|
items: { type: "integer" }
|
|
129
129
|
},
|
|
130
|
+
// Embedded from wcpos/v2/variations; WCPOS 1.10.x has no per-product route.
|
|
131
|
+
variation_docs: { type: "array", items: { type: "object", properties: {
|
|
132
|
+
id: { type: "integer" },
|
|
133
|
+
sku: { type: "string" },
|
|
134
|
+
barcode: { type: "string" },
|
|
135
|
+
price: { type: "string" },
|
|
136
|
+
regular_price: { type: "string" },
|
|
137
|
+
sale_price: { type: "string" },
|
|
138
|
+
on_sale: { type: "boolean" },
|
|
139
|
+
stock_status: { type: "string" },
|
|
140
|
+
stock_quantity: { type: ["number", "null"] },
|
|
141
|
+
manage_stock: { type: "boolean" },
|
|
142
|
+
status: { type: "string" },
|
|
143
|
+
purchasable: { type: "boolean" },
|
|
144
|
+
attributes: { type: "array", items: { type: "object", properties: { name: { type: "string" }, option: { type: "string" } } } }
|
|
145
|
+
} } },
|
|
130
146
|
meta_data: {
|
|
131
147
|
type: "array",
|
|
132
148
|
items: {
|
|
@@ -202,6 +218,36 @@ function wooProductBarcode(product) {
|
|
|
202
218
|
if (typeof product.global_unique_id === "string" && product.global_unique_id.length > 0) return product.global_unique_id;
|
|
203
219
|
return void 0;
|
|
204
220
|
}
|
|
221
|
+
function toVariationDocument(payload) {
|
|
222
|
+
const {
|
|
223
|
+
id,
|
|
224
|
+
sku,
|
|
225
|
+
price,
|
|
226
|
+
regular_price,
|
|
227
|
+
sale_price,
|
|
228
|
+
on_sale,
|
|
229
|
+
stock_status,
|
|
230
|
+
stock_quantity,
|
|
231
|
+
manage_stock,
|
|
232
|
+
status,
|
|
233
|
+
purchasable
|
|
234
|
+
} = payload;
|
|
235
|
+
return Object.fromEntries(Object.entries({
|
|
236
|
+
id,
|
|
237
|
+
sku,
|
|
238
|
+
barcode: wooProductBarcode(payload),
|
|
239
|
+
price,
|
|
240
|
+
regular_price,
|
|
241
|
+
sale_price,
|
|
242
|
+
on_sale,
|
|
243
|
+
stock_status,
|
|
244
|
+
stock_quantity,
|
|
245
|
+
manage_stock,
|
|
246
|
+
status,
|
|
247
|
+
purchasable,
|
|
248
|
+
attributes: (payload.attributes ?? []).map(({ name, option }) => ({ name, option }))
|
|
249
|
+
}).filter(([, value]) => value !== void 0));
|
|
250
|
+
}
|
|
205
251
|
function toProductDocument(product) {
|
|
206
252
|
const uuid = wooProductUuid(product);
|
|
207
253
|
if (uuid === void 0) throw new WooMissingUuidError(product.id);
|
|
@@ -290,6 +336,28 @@ var wooProductReplication = {
|
|
|
290
336
|
}
|
|
291
337
|
return document;
|
|
292
338
|
});
|
|
339
|
+
const variableProducts = documents.filter((doc) => doc.type === "variable" && !doc._deleted);
|
|
340
|
+
const variationIds = variableProducts.flatMap((doc) => doc.variations ?? []);
|
|
341
|
+
const variations = /* @__PURE__ */ new Map();
|
|
342
|
+
for (let i = 0; i < variationIds.length; i += 100) {
|
|
343
|
+
const chunk = variationIds.slice(i, i + 100);
|
|
344
|
+
const variationParams = new URLSearchParams({ include: chunk.join(","), per_page: String(chunk.length), orderby: "id", order: "asc" });
|
|
345
|
+
const response2 = await fetch(`${context.baseUrl}/variations?${variationParams}`, {
|
|
346
|
+
headers: { ...context.headers, "Content-Type": "application/json" },
|
|
347
|
+
signal: context.signal
|
|
348
|
+
});
|
|
349
|
+
await checkResponse(response2);
|
|
350
|
+
const body = await response2.json();
|
|
351
|
+
for (const doc of body.documents) {
|
|
352
|
+
const parentId = doc.parent_id ?? doc.payload.parent_id;
|
|
353
|
+
const group = variations.get(parentId) ?? [];
|
|
354
|
+
group.push(toVariationDocument(doc.payload));
|
|
355
|
+
variations.set(parentId, group);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
for (const doc of variableProducts) {
|
|
359
|
+
doc.variation_docs = (variations.get(doc.id) ?? []).sort((a, b) => a.id - b.id);
|
|
360
|
+
}
|
|
293
361
|
const complete = count === void 0 ? products.length < batchSize : offset + products.length >= count;
|
|
294
362
|
const checkpoint = complete ? { modified: passMark, offset: 0, pass_mark: void 0, pass_count: void 0 } : { modified, offset: offset + products.length, pass_mark: passMark, pass_count: count };
|
|
295
363
|
if (complete && products.length === 0) {
|
|
@@ -340,6 +408,21 @@ var wooProductTraits = {
|
|
|
340
408
|
},
|
|
341
409
|
getStockQuantity: (doc) => doc.stock_quantity ?? null,
|
|
342
410
|
hasVariants: (doc) => doc.type === "variable",
|
|
411
|
+
// Variable products carry their synced variations for synchronous, offline traits.
|
|
412
|
+
getVariants: (doc, context) => doc.type === "variable" ? (doc.variation_docs ?? []).filter((v) => v.status === "publish" && v.purchasable !== false).map((v) => ({
|
|
413
|
+
id: String(v.id),
|
|
414
|
+
title: (v.attributes ?? []).map((attribute) => attribute.option).join(" / ") || void 0,
|
|
415
|
+
sku: v.sku || void 0,
|
|
416
|
+
barcode: v.barcode || void 0,
|
|
417
|
+
prices: wooProductTraits.getPrices(v, context),
|
|
418
|
+
stock: wooProductTraits.getStock(v)
|
|
419
|
+
})) : [{
|
|
420
|
+
id: String(doc.id),
|
|
421
|
+
sku: doc.sku || void 0,
|
|
422
|
+
barcode: wooProductBarcode(doc),
|
|
423
|
+
prices: wooProductTraits.getPrices(doc, context),
|
|
424
|
+
stock: wooProductTraits.getStock(doc)
|
|
425
|
+
}],
|
|
343
426
|
isSellable: (doc) => doc.status === void 0 || doc.status === "publish",
|
|
344
427
|
getVariantCount: (doc) => doc.type === "variable" ? doc.variations?.length ?? 0 : 1,
|
|
345
428
|
getType: (doc) => doc.type ?? "simple",
|
|
@@ -510,7 +593,137 @@ function createWooReconcileFeed() {
|
|
|
510
593
|
}
|
|
511
594
|
|
|
512
595
|
// package.json
|
|
513
|
-
var version = "3.0.
|
|
596
|
+
var version = "3.0.3";
|
|
597
|
+
|
|
598
|
+
// src/commands/transport.ts
|
|
599
|
+
import { minorUnitDigits, moneyFromDecimalString, moneyFromMajor as moneyFromMajor2 } from "@tallyui/core";
|
|
600
|
+
function wooMinorFromDecimal(text, currency) {
|
|
601
|
+
const trimmed = text.trim();
|
|
602
|
+
if (!/^\d+(\.\d+)?$/.test(trimmed)) return void 0;
|
|
603
|
+
const digits = minorUnitDigits(currency);
|
|
604
|
+
const [whole, fraction = ""] = trimmed.split(".");
|
|
605
|
+
let end = fraction.length;
|
|
606
|
+
while (end > digits && fraction[end - 1] === "0") end--;
|
|
607
|
+
if (end > digits) return moneyFromMajor2(trimmed, currency)?.amount;
|
|
608
|
+
return moneyFromDecimalString(end ? `${whole}.${fraction.slice(0, end)}` : whole, currency)?.amount;
|
|
609
|
+
}
|
|
610
|
+
function toWooOrderPayload(envelope) {
|
|
611
|
+
const p = envelope.payload;
|
|
612
|
+
if (p.pricesIncludeTax || p.lines.some((line) => line.taxInclusive === true)) {
|
|
613
|
+
return { error: { code: "unsupported_tax_mode", message: "Prices that include tax are not supported for WooCommerce yet." } };
|
|
614
|
+
}
|
|
615
|
+
if (p.payments.length !== 1) {
|
|
616
|
+
return { error: { code: "invalid_payload", message: "WooCommerce orders take one payment." } };
|
|
617
|
+
}
|
|
618
|
+
for (const line of p.lines) {
|
|
619
|
+
if (!/^[1-9]\d*$/.test(line.variantId) || !Number.isSafeInteger(Number(line.variantId))) {
|
|
620
|
+
return { error: { code: "invalid_payload", message: `Line ${line.clientLineId} has no WooCommerce product id.` } };
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
const digits = p.display?.exponent ?? minorUnitDigits(p.currency);
|
|
624
|
+
const major = (minor) => (minor / 10 ** digits).toFixed(digits);
|
|
625
|
+
const cash = p.payments[0].method === "cash";
|
|
626
|
+
return { payload: {
|
|
627
|
+
status: "completed",
|
|
628
|
+
set_paid: true,
|
|
629
|
+
currency: p.currency,
|
|
630
|
+
payment_method: cash ? "pos_cash" : "pos_card",
|
|
631
|
+
payment_method_title: cash ? "Cash" : "Card",
|
|
632
|
+
line_items: p.lines.map((line) => ({
|
|
633
|
+
product_id: Number(line.variantId),
|
|
634
|
+
quantity: line.quantity,
|
|
635
|
+
subtotal: major(line.unitPriceMinor * line.quantity),
|
|
636
|
+
total: major(line.unitPriceMinor * line.quantity - (line.discountMinor ?? 0))
|
|
637
|
+
})),
|
|
638
|
+
meta_data: [{ key: "_woocommerce_pos_uuid", value: p.clientOrderId }],
|
|
639
|
+
...p.customer?.customerId && /^\d+$/.test(p.customer.customerId) ? { customer_id: Number(p.customer.customerId) } : {},
|
|
640
|
+
...p.customer?.email ? { billing: { email: p.customer.email } } : {}
|
|
641
|
+
} };
|
|
642
|
+
}
|
|
643
|
+
function createWooCommandTransport(options) {
|
|
644
|
+
const fetch2 = options.fetch ?? globalThis.fetch;
|
|
645
|
+
let baseUrl = options.baseUrl;
|
|
646
|
+
while (baseUrl.endsWith("/")) baseUrl = baseUrl.slice(0, -1);
|
|
647
|
+
return {
|
|
648
|
+
async send(batch) {
|
|
649
|
+
const results = [];
|
|
650
|
+
for (const envelope of batch) {
|
|
651
|
+
const mapped = toWooOrderPayload(envelope);
|
|
652
|
+
if ("error" in mapped) {
|
|
653
|
+
results.push({ id: envelope.id, status: "rejected", error: mapped.error });
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
const controller = new AbortController();
|
|
657
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 3e4);
|
|
658
|
+
let outcome;
|
|
659
|
+
try {
|
|
660
|
+
const response = await fetch2(`${baseUrl}/push/orders`, {
|
|
661
|
+
method: "POST",
|
|
662
|
+
headers: { "Content-Type": "application/json", ...await options.getHeaders() },
|
|
663
|
+
body: JSON.stringify({
|
|
664
|
+
mutationId: envelope.id,
|
|
665
|
+
operation: "create",
|
|
666
|
+
collection: "orders",
|
|
667
|
+
recordId: envelope.payload.clientOrderId,
|
|
668
|
+
baseRevision: null,
|
|
669
|
+
payload: mapped.payload
|
|
670
|
+
}),
|
|
671
|
+
signal: controller.signal
|
|
672
|
+
});
|
|
673
|
+
const status = response.status;
|
|
674
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
675
|
+
const seconds = retryAfter === null ? NaN : Number(retryAfter);
|
|
676
|
+
const retry = (reason) => ({
|
|
677
|
+
kind: "retry",
|
|
678
|
+
reason,
|
|
679
|
+
...Number.isFinite(seconds) && seconds >= 0 ? { retryAfterMs: seconds * 1e3 } : {}
|
|
680
|
+
});
|
|
681
|
+
if (status === 401) outcome = { kind: "unauthorized" };
|
|
682
|
+
else if (![200, 201, 400, 422, 403, 413, 415].includes(status)) outcome = retry(`status_${status}`);
|
|
683
|
+
else {
|
|
684
|
+
let body;
|
|
685
|
+
try {
|
|
686
|
+
body = await response.json();
|
|
687
|
+
} catch {
|
|
688
|
+
}
|
|
689
|
+
if (status === 200 || status === 201) {
|
|
690
|
+
const doc = body?.document;
|
|
691
|
+
const totalMinor = wooMinorFromDecimal(String(doc?.total), envelope.payload.currency);
|
|
692
|
+
if (doc?.id == null || totalMinor === void 0) {
|
|
693
|
+
outcome = retry(controller.signal.aborted ? "timeout" : "bad_body");
|
|
694
|
+
} else {
|
|
695
|
+
results.push({ id: envelope.id, status: "applied", serverRefs: {
|
|
696
|
+
orderId: String(doc.id),
|
|
697
|
+
displayId: String(doc.number ?? doc.id),
|
|
698
|
+
totalMinor
|
|
699
|
+
}, ...totalMinor !== envelope.payload.totalMinor ? { warnings: [{
|
|
700
|
+
code: "total_mismatch",
|
|
701
|
+
expectedMinor: envelope.payload.totalMinor,
|
|
702
|
+
serverMinor: totalMinor
|
|
703
|
+
}] } : {} });
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
} else if (status === 400 || status === 422) {
|
|
707
|
+
results.push({ id: envelope.id, status: "rejected", error: {
|
|
708
|
+
code: status === 400 && body?.code === "wcpos_insufficient_stock" ? "insufficient_stock" : "invalid_payload",
|
|
709
|
+
message: body?.message ?? `status_${status}`
|
|
710
|
+
} });
|
|
711
|
+
continue;
|
|
712
|
+
} else {
|
|
713
|
+
outcome = { kind: "refused", status, reason: body?.message ?? body?.code ?? `status_${status}` };
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
} catch {
|
|
717
|
+
outcome = { kind: "retry", reason: controller.signal.aborted ? "timeout" : "network" };
|
|
718
|
+
} finally {
|
|
719
|
+
clearTimeout(timeout);
|
|
720
|
+
}
|
|
721
|
+
return results.length ? { kind: "results", results } : outcome;
|
|
722
|
+
}
|
|
723
|
+
return { kind: "results", results };
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
}
|
|
514
727
|
|
|
515
728
|
// src/index.ts
|
|
516
729
|
import { ConnectorUnauthorizedError as ConnectorUnauthorizedError3 } from "@tallyui/core";
|
|
@@ -594,7 +807,9 @@ export {
|
|
|
594
807
|
WooMissingUuidError,
|
|
595
808
|
WooTillUpdateRequiredError,
|
|
596
809
|
WooTokenRefusedError,
|
|
810
|
+
createWooCommandTransport,
|
|
597
811
|
createWooCommerceConnector,
|
|
812
|
+
toWooOrderPayload,
|
|
598
813
|
wcposClientPart,
|
|
599
814
|
wooCatalogueReconcile,
|
|
600
815
|
wooProductReplication,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tallyui/connector-woocommerce",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.3",
|
|
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.3"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@tallyui/database": "3.0.
|
|
32
|
+
"@tallyui/database": "3.0.3",
|
|
33
33
|
"rxdb": "17.5.0"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// ADR-073; M3-spike-paid-push.md proves paid order pushes against WCPOS 1.10.20.
|
|
2
|
+
import { minorUnitDigits, moneyFromDecimalString, moneyFromMajor, type CommandResult, type OrderCreateEnvelope } from '@tallyui/core';
|
|
3
|
+
|
|
4
|
+
export type WooTransportOutcome =
|
|
5
|
+
| { kind: 'results'; results: CommandResult[] }
|
|
6
|
+
| { kind: 'unauthorized' }
|
|
7
|
+
| { kind: 'refused'; status: number; reason: string }
|
|
8
|
+
| { kind: 'retry'; reason: string; retryAfterMs?: number };
|
|
9
|
+
|
|
10
|
+
export interface WooCommandTransportOptions {
|
|
11
|
+
baseUrl: string;
|
|
12
|
+
getHeaders: () => Record<string, string> | Promise<Record<string, string>>;
|
|
13
|
+
fetch?: typeof fetch;
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function wooMinorFromDecimal(text: string, currency: string): number | undefined {
|
|
18
|
+
const trimmed = text.trim();
|
|
19
|
+
if (!/^\d+(\.\d+)?$/.test(trimmed)) return undefined;
|
|
20
|
+
const digits = minorUnitDigits(currency);
|
|
21
|
+
const [whole, fraction = ''] = trimmed.split('.');
|
|
22
|
+
let end = fraction.length;
|
|
23
|
+
while (end > digits && fraction[end - 1] === '0') end--;
|
|
24
|
+
if (end > digits) return moneyFromMajor(trimmed, currency)?.amount;
|
|
25
|
+
return moneyFromDecimalString(end ? `${whole}.${fraction.slice(0, end)}` : whole, currency)?.amount;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function toWooOrderPayload(envelope: OrderCreateEnvelope): { payload: Record<string, unknown> } | { error: { code: string; message: string } } {
|
|
29
|
+
const p = envelope.payload;
|
|
30
|
+
if (p.pricesIncludeTax || p.lines.some((line) => line.taxInclusive === true)) {
|
|
31
|
+
return { error: { code: 'unsupported_tax_mode', message: 'Prices that include tax are not supported for WooCommerce yet.' } };
|
|
32
|
+
}
|
|
33
|
+
if (p.payments.length !== 1) {
|
|
34
|
+
return { error: { code: 'invalid_payload', message: 'WooCommerce orders take one payment.' } };
|
|
35
|
+
}
|
|
36
|
+
for (const line of p.lines) {
|
|
37
|
+
if (!/^[1-9]\d*$/.test(line.variantId) || !Number.isSafeInteger(Number(line.variantId))) {
|
|
38
|
+
return { error: { code: 'invalid_payload', message: `Line ${line.clientLineId} has no WooCommerce product id.` } };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const digits = p.display?.exponent ?? minorUnitDigits(p.currency);
|
|
42
|
+
const major = (minor: number) => (minor / 10 ** digits).toFixed(digits);
|
|
43
|
+
const cash = p.payments[0].method === 'cash';
|
|
44
|
+
return { payload: {
|
|
45
|
+
status: 'completed', set_paid: true, currency: p.currency,
|
|
46
|
+
payment_method: cash ? 'pos_cash' : 'pos_card', payment_method_title: cash ? 'Cash' : 'Card',
|
|
47
|
+
line_items: p.lines.map((line) => ({
|
|
48
|
+
product_id: Number(line.variantId), quantity: line.quantity,
|
|
49
|
+
subtotal: major(line.unitPriceMinor * line.quantity),
|
|
50
|
+
total: major(line.unitPriceMinor * line.quantity - (line.discountMinor ?? 0)),
|
|
51
|
+
})),
|
|
52
|
+
meta_data: [{ key: '_woocommerce_pos_uuid', value: p.clientOrderId }],
|
|
53
|
+
...(p.customer?.customerId && /^\d+$/.test(p.customer.customerId) ? { customer_id: Number(p.customer.customerId) } : {}),
|
|
54
|
+
...(p.customer?.email ? { billing: { email: p.customer.email } } : {}),
|
|
55
|
+
} };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createWooCommandTransport(options: WooCommandTransportOptions): { send(batch: OrderCreateEnvelope[]): Promise<WooTransportOutcome> } {
|
|
59
|
+
const fetch = options.fetch ?? globalThis.fetch;
|
|
60
|
+
let baseUrl = options.baseUrl;
|
|
61
|
+
while (baseUrl.endsWith('/')) baseUrl = baseUrl.slice(0, -1);
|
|
62
|
+
return {
|
|
63
|
+
async send(batch) {
|
|
64
|
+
const results: CommandResult[] = [];
|
|
65
|
+
for (const envelope of batch) {
|
|
66
|
+
const mapped = toWooOrderPayload(envelope);
|
|
67
|
+
if ('error' in mapped) {
|
|
68
|
+
results.push({ id: envelope.id, status: 'rejected', error: mapped.error });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 30000);
|
|
73
|
+
let outcome: WooTransportOutcome;
|
|
74
|
+
try {
|
|
75
|
+
const response = await fetch(`${baseUrl}/push/orders`, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'Content-Type': 'application/json', ...await options.getHeaders() },
|
|
78
|
+
body: JSON.stringify({ mutationId: envelope.id, operation: 'create', collection: 'orders',
|
|
79
|
+
recordId: envelope.payload.clientOrderId, baseRevision: null, payload: mapped.payload }),
|
|
80
|
+
signal: controller.signal,
|
|
81
|
+
});
|
|
82
|
+
const status = response.status;
|
|
83
|
+
const retryAfter = response.headers.get('Retry-After');
|
|
84
|
+
const seconds = retryAfter === null ? NaN : Number(retryAfter);
|
|
85
|
+
const retry = (reason: string): WooTransportOutcome => ({ kind: 'retry', reason,
|
|
86
|
+
...(Number.isFinite(seconds) && seconds >= 0 ? { retryAfterMs: seconds * 1000 } : {}),
|
|
87
|
+
});
|
|
88
|
+
if (status === 401) outcome = { kind: 'unauthorized' };
|
|
89
|
+
else if (![200, 201, 400, 422, 403, 413, 415].includes(status)) outcome = retry(`status_${status}`);
|
|
90
|
+
else {
|
|
91
|
+
let body;
|
|
92
|
+
try { body = await response.json(); } catch {}
|
|
93
|
+
if (status === 200 || status === 201) {
|
|
94
|
+
const doc = body?.document;
|
|
95
|
+
const totalMinor = wooMinorFromDecimal(String(doc?.total), envelope.payload.currency);
|
|
96
|
+
if (doc?.id == null || totalMinor === undefined) {
|
|
97
|
+
outcome = retry(controller.signal.aborted ? 'timeout' : 'bad_body');
|
|
98
|
+
} else {
|
|
99
|
+
results.push({ id: envelope.id, status: 'applied', serverRefs: {
|
|
100
|
+
orderId: String(doc.id), displayId: String(doc.number ?? doc.id), totalMinor,
|
|
101
|
+
}, ...(totalMinor !== envelope.payload.totalMinor ? { warnings: [{
|
|
102
|
+
code: 'total_mismatch' as const, expectedMinor: envelope.payload.totalMinor, serverMinor: totalMinor,
|
|
103
|
+
}] } : {}) });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
} else if (status === 400 || status === 422) {
|
|
107
|
+
results.push({ id: envelope.id, status: 'rejected', error: {
|
|
108
|
+
code: status === 400 && body?.code === 'wcpos_insufficient_stock' ? 'insufficient_stock' : 'invalid_payload',
|
|
109
|
+
message: body?.message ?? `status_${status}`,
|
|
110
|
+
} });
|
|
111
|
+
continue;
|
|
112
|
+
} else {
|
|
113
|
+
outcome = { kind: 'refused', status, reason: body?.message ?? body?.code ?? `status_${status}` };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
outcome = { kind: 'retry', reason: controller.signal.aborted ? 'timeout' : 'network' };
|
|
118
|
+
} finally {
|
|
119
|
+
clearTimeout(timeout);
|
|
120
|
+
}
|
|
121
|
+
return results.length ? { kind: 'results', results } : outcome;
|
|
122
|
+
}
|
|
123
|
+
return { kind: 'results', results };
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -115,6 +115,7 @@ const wooConnectorParts = {
|
|
|
115
115
|
export const woocommerceConnector: TallyConnector = createWooCommerceConnector();
|
|
116
116
|
|
|
117
117
|
// Re-export pieces for advanced usage
|
|
118
|
+
export { createWooCommandTransport, toWooOrderPayload, type WooCommandTransportOptions } from './commands/transport';
|
|
118
119
|
export { ConnectorUnauthorizedError } from '@tallyui/core';
|
|
119
120
|
export { wooProductSchema } from './schemas/products';
|
|
120
121
|
export { wooProductTraits } from './traits/product';
|
|
@@ -13,7 +13,7 @@ export type WooProductCheckpoint = {
|
|
|
13
13
|
};
|
|
14
14
|
|
|
15
15
|
// RxDB drops the checkpoint of an empty result, so the handler moves on to the next useful request
|
|
16
|
-
// in the same call instead;
|
|
16
|
+
// in the same call instead; product and mark requests in one call count against this.
|
|
17
17
|
const MAX_REQUESTS_PER_CALL = 4;
|
|
18
18
|
|
|
19
19
|
/** The most of a foreign 426's message kept in the error, so a store's long page never floods the log; longer is cut with "…". */
|
|
@@ -83,6 +83,16 @@ export function wooProductBarcode(product: any): string | undefined {
|
|
|
83
83
|
return undefined;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
export function toVariationDocument(payload: any) {
|
|
87
|
+
const { id, sku, price, regular_price, sale_price, on_sale, stock_status,
|
|
88
|
+
stock_quantity, manage_stock, status, purchasable } = payload;
|
|
89
|
+
return Object.fromEntries(Object.entries({
|
|
90
|
+
id, sku, barcode: wooProductBarcode(payload), price, regular_price, sale_price, on_sale,
|
|
91
|
+
stock_status, stock_quantity, manage_stock, status, purchasable,
|
|
92
|
+
attributes: (payload.attributes ?? []).map(({ name, option }: any) => ({ name, option })),
|
|
93
|
+
}).filter(([, value]) => value !== undefined));
|
|
94
|
+
}
|
|
95
|
+
|
|
86
96
|
/**
|
|
87
97
|
* One product row as the till stores it, the rule the pull and the reconcile feed share: a uuid is
|
|
88
98
|
* required (WooMissingUuidError), and a product that is not published arrives deleted (#229).
|
|
@@ -205,6 +215,29 @@ export const wooProductReplication: ReplicationAdapter<any, WooProductCheckpoint
|
|
|
205
215
|
return document;
|
|
206
216
|
});
|
|
207
217
|
|
|
218
|
+
const variableProducts = documents.filter((doc) => doc.type === 'variable' && !doc._deleted);
|
|
219
|
+
const variationIds = variableProducts.flatMap((doc) => doc.variations ?? []);
|
|
220
|
+
const variations = new Map<number, ReturnType<typeof toVariationDocument>[]>();
|
|
221
|
+
// Hydration requests complete the page already fetched, outside MAX_REQUESTS_PER_CALL.
|
|
222
|
+
for (let i = 0; i < variationIds.length; i += 100) {
|
|
223
|
+
const chunk = variationIds.slice(i, i + 100);
|
|
224
|
+
const variationParams = new URLSearchParams({ include: chunk.join(','), per_page: String(chunk.length), orderby: 'id', order: 'asc' });
|
|
225
|
+
const response = await fetch(`${context.baseUrl}/variations?${variationParams}`, {
|
|
226
|
+
headers: { ...context.headers, 'Content-Type': 'application/json' }, signal: context.signal,
|
|
227
|
+
});
|
|
228
|
+
await checkResponse(response);
|
|
229
|
+
const body = await response.json();
|
|
230
|
+
for (const doc of body.documents) {
|
|
231
|
+
const parentId = doc.parent_id ?? doc.payload.parent_id;
|
|
232
|
+
const group = variations.get(parentId) ?? [];
|
|
233
|
+
group.push(toVariationDocument(doc.payload));
|
|
234
|
+
variations.set(parentId, group);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for (const doc of variableProducts) {
|
|
238
|
+
doc.variation_docs = (variations.get(doc.id) ?? []).sort((a, b) => a.id - b.id);
|
|
239
|
+
}
|
|
240
|
+
|
|
208
241
|
// RxDB merges checkpoints, so clear pass state explicitly at completion.
|
|
209
242
|
const complete = count === undefined ? products.length < batchSize : offset + products.length >= count;
|
|
210
243
|
const checkpoint: WooProductCheckpoint = complete
|
package/src/schemas/products.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { RxJsonSchema } from 'rxdb';
|
|
|
7
7
|
*/
|
|
8
8
|
export const wooProductSchema: RxJsonSchema<any> = {
|
|
9
9
|
title: 'WooCommerce Product',
|
|
10
|
-
version:
|
|
10
|
+
version: 1,
|
|
11
11
|
type: 'object',
|
|
12
12
|
primaryKey: 'uuid',
|
|
13
13
|
properties: {
|
|
@@ -130,6 +130,14 @@ export const wooProductSchema: RxJsonSchema<any> = {
|
|
|
130
130
|
type: 'array',
|
|
131
131
|
items: { type: 'integer' },
|
|
132
132
|
},
|
|
133
|
+
// Embedded from wcpos/v2/variations; WCPOS 1.10.x has no per-product route.
|
|
134
|
+
variation_docs: { type: 'array', items: { type: 'object', properties: {
|
|
135
|
+
id: { type: 'integer' }, sku: { type: 'string' }, barcode: { type: 'string' },
|
|
136
|
+
price: { type: 'string' }, regular_price: { type: 'string' }, sale_price: { type: 'string' },
|
|
137
|
+
on_sale: { type: 'boolean' }, stock_status: { type: 'string' }, stock_quantity: { type: ['number', 'null'] },
|
|
138
|
+
manage_stock: { type: 'boolean' }, status: { type: 'string' }, purchasable: { type: 'boolean' },
|
|
139
|
+
attributes: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, option: { type: 'string' } } } },
|
|
140
|
+
} } },
|
|
133
141
|
meta_data: {
|
|
134
142
|
type: 'array',
|
|
135
143
|
items: {
|
package/src/traits/product.ts
CHANGED
|
@@ -64,6 +64,23 @@ export const wooProductTraits: ProductTraits = {
|
|
|
64
64
|
|
|
65
65
|
hasVariants: (doc) => doc.type === 'variable',
|
|
66
66
|
|
|
67
|
+
// Variable products carry their synced variations for synchronous, offline traits.
|
|
68
|
+
getVariants: (doc, context) => doc.type === 'variable' ? (doc.variation_docs ?? [])
|
|
69
|
+
.filter((v: any) => v.status === 'publish' && v.purchasable !== false).map((v: any) => ({
|
|
70
|
+
id: String(v.id),
|
|
71
|
+
title: (v.attributes ?? []).map((attribute: any) => attribute.option).join(' / ') || undefined,
|
|
72
|
+
sku: v.sku || undefined,
|
|
73
|
+
barcode: v.barcode || undefined,
|
|
74
|
+
prices: wooProductTraits.getPrices(v, context),
|
|
75
|
+
stock: wooProductTraits.getStock(v),
|
|
76
|
+
})) : [{
|
|
77
|
+
id: String(doc.id),
|
|
78
|
+
sku: doc.sku || undefined,
|
|
79
|
+
barcode: wooProductBarcode(doc),
|
|
80
|
+
prices: wooProductTraits.getPrices(doc, context),
|
|
81
|
+
stock: wooProductTraits.getStock(doc),
|
|
82
|
+
}],
|
|
83
|
+
|
|
67
84
|
isSellable: (doc) => doc.status === undefined || doc.status === 'publish',
|
|
68
85
|
|
|
69
86
|
getVariantCount: (doc) => doc.type === 'variable' ? doc.variations?.length ?? 0 : 1,
|