@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 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: 0,
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.1";
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.1",
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.1"
29
+ "@tallyui/core": "^3.0.3"
30
30
  },
31
31
  "devDependencies": {
32
- "@tallyui/database": "3.0.1",
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; every fetch in one call, mark requests included, counts against this.
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
@@ -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: 0,
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: {
@@ -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,