@tallyui/connector-woocommerce 3.7.1 → 3.9.0
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 +12 -0
- package/dist/index.d.ts +28 -2
- package/dist/index.js +251 -5
- package/package.json +5 -4
- package/src/index.ts +9 -0
- package/src/reconcile/coupons.ts +85 -0
- package/src/replication/coupons.ts +16 -0
- package/src/replication/products.ts +3 -1
- package/src/schemas/coupons.ts +122 -0
- package/src/store-settings.ts +28 -0
- package/src/traits/product.ts +19 -0
package/README.md
CHANGED
|
@@ -35,6 +35,18 @@ The incremental pull can miss an edit: a `modified_after` that over-excludes (in
|
|
|
35
35
|
- When `wcpos/v2/status` lists `products_id_fast_path` in `capabilities`, the whole catalogue is listed in one request (`per_page=-1` with `_fields=id,date_modified_gmt,stock_quantity,stock_status`) instead of pages of 100; a store that refuses it (a 400) is listed page by page in the same pass.
|
|
36
36
|
- Both listings key on the numeric product id, never the till-local uuid; the adapter's `matchKey` is how the runner finds the till's copy.
|
|
37
37
|
|
|
38
|
+
## Capabilities
|
|
39
|
+
|
|
40
|
+
`readWooCapabilities` (the connector's `capabilities`) runs when the till signs in or restores a session. The app keeps the result with the session.
|
|
41
|
+
- **`GET wcpos/v2/stores`** gives the tax rounding (`tax_round_at_subtotal`). A failed or malformed read returns `undefined`: nothing new is known, and the stored capabilities stay.
|
|
42
|
+
- **`GET wcpos/v2/status`.** The strings in its `capabilities` decide two fields:
|
|
43
|
+
- `order_create_v5` sets `orderCreate` to 5; without it, `orderCreate` is 3. Only the TallyUI fork of the plugin advertises it, and the site version never decides it.
|
|
44
|
+
- `order_payments_list` sets `multiplePayments`.
|
|
45
|
+
- **`GET wcpos/v2/site`.** Its `wcpos_version` decides `coupons` (ADR-077, ruling R3). It is true only for woocommerce-pos 1.9.0 or later; a 1.9.0 pre-release does not count. Any failed or unclear read gives false, and the till then refuses to apply a coupon.
|
|
46
|
+
- **`lineTax`** is always `{ none: true, classes: true }`.
|
|
47
|
+
- **Unauthorised reads.** A 401 or 403 from `/stores` or `/status` throws `ConnectorUnauthorizedError`. A 401 or 403 from `/site` only closes the coupon gate.
|
|
48
|
+
- **`products_id_fast_path`** is read by the reconcile pass, not here (see Reconciliation).
|
|
49
|
+
|
|
38
50
|
## Customers
|
|
39
51
|
|
|
40
52
|
On WCPOS 1.10.x, `searchCustomers` uses `GET wcpos/v2/customers` with `role=customer`.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SyncContext, ServerCapabilities, StoreSettings, Customer, CustomerInput, OrderCreateEnvelope, CommandResult,
|
|
1
|
+
import { SyncContext, ServerCapabilities, StoreSettings, Customer, CustomerInput, OrderCreateEnvelope, CommandResult, ReconcileFeed, CatalogueReconcileAdapter, ProductTraits, CollectionSync, ReplicationAdapter, ConnectorUnauthorizedError, TallyConnector } from '@tallyui/core';
|
|
2
2
|
export { ConnectorUnauthorizedError } from '@tallyui/core';
|
|
3
3
|
import { RxJsonSchema } from 'rxdb';
|
|
4
4
|
|
|
@@ -96,6 +96,32 @@ declare function createWooCommandTransport(options: WooCommandTransportOptions):
|
|
|
96
96
|
*/
|
|
97
97
|
declare const wooProductSchema: RxJsonSchema<any>;
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* WooCommerce Coupon RxDB schema (ADR-077 amendment 2).
|
|
101
|
+
* Mirrors the coupon listing; the catalogue reconcile is its only source.
|
|
102
|
+
*/
|
|
103
|
+
declare const wooCouponSchema: RxJsonSchema<any>;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One coupon row as the till stores it: a coupon without a uuid cannot be keyed and is skipped, never thrown,
|
|
107
|
+
* so one bad row cannot stall the coupons' pull. A coupon that is not published arrives deleted.
|
|
108
|
+
*/
|
|
109
|
+
declare function toCouponDocument(coupon: any): any | undefined;
|
|
110
|
+
|
|
111
|
+
/** Coupon usage never moves the modified time (ADR-077 amendment 2); numeric used_by ids match stored strings. */
|
|
112
|
+
declare const wooCouponFingerprint: (c: {
|
|
113
|
+
date_modified_gmt?: string | null;
|
|
114
|
+
usage_count?: number | null;
|
|
115
|
+
used_by?: readonly unknown[] | null;
|
|
116
|
+
}) => string;
|
|
117
|
+
/** Coupons reach the collection only through this feed, keyed by uuid and matched to new listing entries by id. */
|
|
118
|
+
declare function createWooCouponFeed(): ReconcileFeed<any>;
|
|
119
|
+
/**
|
|
120
|
+
* The coupons' only source (ADR-077 amendment 2): page through published coupons, with no date filter,
|
|
121
|
+
* and hand differences to the feed. confirmGone proves deletion by re-reading the published listing by id.
|
|
122
|
+
*/
|
|
123
|
+
declare function wooCouponReconcile(feed: Pick<ReconcileFeed<any>, 'enqueue'>): CatalogueReconcileAdapter<any, number>;
|
|
124
|
+
|
|
99
125
|
/**
|
|
100
126
|
* WooCommerce product trait implementations.
|
|
101
127
|
*
|
|
@@ -226,4 +252,4 @@ declare function createWooCommerceConnector(): TallyConnector;
|
|
|
226
252
|
*/
|
|
227
253
|
declare const woocommerceConnector: TallyConnector;
|
|
228
254
|
|
|
229
|
-
export { WOO_ORDER_CREATE_VERSION, type WooCommandTransportOptions, WooDateFilterError, WooMissingTokenError, WooMissingUuidError, WooTillUpdateRequiredError, WooTokenRefusedError, createWooCommandTransport, createWooCommerceConnector, createWooCustomers, readWooCapabilities, toWooCustomer, toWooOrderPayload, wcposClientPart, wooCatalogueReconcile, wooProductReplication, wooProductSchema, wooProductSync, wooProductTraits, wooReconcileFingerprint, wooStoreSettings, woocommerceConnector };
|
|
255
|
+
export { WOO_ORDER_CREATE_VERSION, type WooCommandTransportOptions, WooDateFilterError, WooMissingTokenError, WooMissingUuidError, WooTillUpdateRequiredError, WooTokenRefusedError, createWooCommandTransport, createWooCommerceConnector, createWooCouponFeed, createWooCustomers, readWooCapabilities, toCouponDocument, toWooCustomer, toWooOrderPayload, wcposClientPart, wooCatalogueReconcile, wooCouponFingerprint, wooCouponReconcile, wooCouponSchema, wooProductReplication, wooProductSchema, wooProductSync, wooProductTraits, wooReconcileFingerprint, wooStoreSettings, woocommerceConnector };
|
package/dist/index.js
CHANGED
|
@@ -170,6 +170,124 @@ var wooProductSchema = {
|
|
|
170
170
|
required: ["uuid"]
|
|
171
171
|
};
|
|
172
172
|
|
|
173
|
+
// src/schemas/coupons.ts
|
|
174
|
+
var wooCouponSchema = {
|
|
175
|
+
title: "WooCommerce Coupon",
|
|
176
|
+
version: 0,
|
|
177
|
+
type: "object",
|
|
178
|
+
primaryKey: "uuid",
|
|
179
|
+
properties: {
|
|
180
|
+
uuid: {
|
|
181
|
+
type: "string",
|
|
182
|
+
maxLength: 36
|
|
183
|
+
},
|
|
184
|
+
id: {
|
|
185
|
+
type: "integer",
|
|
186
|
+
multipleOf: 1,
|
|
187
|
+
minimum: 0,
|
|
188
|
+
maximum: 2147483647
|
|
189
|
+
},
|
|
190
|
+
code: {
|
|
191
|
+
type: "string",
|
|
192
|
+
maxLength: 255
|
|
193
|
+
},
|
|
194
|
+
status: {
|
|
195
|
+
type: "string"
|
|
196
|
+
},
|
|
197
|
+
amount: {
|
|
198
|
+
type: "string"
|
|
199
|
+
},
|
|
200
|
+
discount_type: {
|
|
201
|
+
type: "string"
|
|
202
|
+
},
|
|
203
|
+
description: {
|
|
204
|
+
type: "string"
|
|
205
|
+
},
|
|
206
|
+
date_created: {
|
|
207
|
+
type: "string"
|
|
208
|
+
},
|
|
209
|
+
date_created_gmt: {
|
|
210
|
+
type: "string"
|
|
211
|
+
},
|
|
212
|
+
date_modified: {
|
|
213
|
+
type: "string"
|
|
214
|
+
},
|
|
215
|
+
date_modified_gmt: {
|
|
216
|
+
type: "string"
|
|
217
|
+
},
|
|
218
|
+
minimum_amount: {
|
|
219
|
+
type: "string"
|
|
220
|
+
},
|
|
221
|
+
maximum_amount: {
|
|
222
|
+
type: "string"
|
|
223
|
+
},
|
|
224
|
+
date_expires: {
|
|
225
|
+
type: ["string", "null"]
|
|
226
|
+
},
|
|
227
|
+
date_expires_gmt: {
|
|
228
|
+
type: ["string", "null"]
|
|
229
|
+
},
|
|
230
|
+
usage_count: {
|
|
231
|
+
type: "integer"
|
|
232
|
+
},
|
|
233
|
+
usage_limit: {
|
|
234
|
+
type: ["integer", "null"]
|
|
235
|
+
},
|
|
236
|
+
usage_limit_per_user: {
|
|
237
|
+
type: ["integer", "null"]
|
|
238
|
+
},
|
|
239
|
+
limit_usage_to_x_items: {
|
|
240
|
+
type: ["integer", "null"]
|
|
241
|
+
},
|
|
242
|
+
individual_use: {
|
|
243
|
+
type: "boolean"
|
|
244
|
+
},
|
|
245
|
+
free_shipping: {
|
|
246
|
+
type: "boolean"
|
|
247
|
+
},
|
|
248
|
+
exclude_sale_items: {
|
|
249
|
+
type: "boolean"
|
|
250
|
+
},
|
|
251
|
+
product_ids: {
|
|
252
|
+
type: "array",
|
|
253
|
+
items: { type: "integer" }
|
|
254
|
+
},
|
|
255
|
+
excluded_product_ids: {
|
|
256
|
+
type: "array",
|
|
257
|
+
items: { type: "integer" }
|
|
258
|
+
},
|
|
259
|
+
product_categories: {
|
|
260
|
+
type: "array",
|
|
261
|
+
items: { type: "integer" }
|
|
262
|
+
},
|
|
263
|
+
excluded_product_categories: {
|
|
264
|
+
type: "array",
|
|
265
|
+
items: { type: "integer" }
|
|
266
|
+
},
|
|
267
|
+
email_restrictions: {
|
|
268
|
+
type: "array",
|
|
269
|
+
items: { type: "string" }
|
|
270
|
+
},
|
|
271
|
+
used_by: {
|
|
272
|
+
type: "array",
|
|
273
|
+
items: { type: "string" }
|
|
274
|
+
},
|
|
275
|
+
meta_data: {
|
|
276
|
+
type: "array",
|
|
277
|
+
items: {
|
|
278
|
+
type: "object",
|
|
279
|
+
properties: {
|
|
280
|
+
id: { type: "integer" },
|
|
281
|
+
key: { type: "string" },
|
|
282
|
+
value: {}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
indexes: ["code"],
|
|
288
|
+
required: ["uuid"]
|
|
289
|
+
};
|
|
290
|
+
|
|
173
291
|
// src/traits/product.ts
|
|
174
292
|
import { moneyFromMajor } from "@tallyui/core";
|
|
175
293
|
|
|
@@ -257,7 +375,11 @@ function toVariationDocument(payload) {
|
|
|
257
375
|
purchasable,
|
|
258
376
|
tax_class,
|
|
259
377
|
tax_status,
|
|
260
|
-
attributes: (payload.attributes ?? []).map(({ name, option }) => ({
|
|
378
|
+
attributes: (payload.attributes ?? []).map(({ id: id2, name, option }) => ({
|
|
379
|
+
...typeof id2 === "number" ? { id: id2 } : {},
|
|
380
|
+
name,
|
|
381
|
+
option
|
|
382
|
+
}))
|
|
261
383
|
}).filter(([, value]) => value !== void 0));
|
|
262
384
|
}
|
|
263
385
|
function toProductDocument(product) {
|
|
@@ -435,6 +557,12 @@ var wooProductTraits = {
|
|
|
435
557
|
getVariants: (doc, context) => doc.type === "variable" ? (doc.variation_docs ?? []).filter((v) => v.status === "publish" && v.purchasable !== false).map((v) => ({
|
|
436
558
|
id: String(v.id),
|
|
437
559
|
title: (v.attributes ?? []).map((attribute) => attribute.option).join(" / ") || void 0,
|
|
560
|
+
options: (v.attributes ?? []).reduce((options, attribute) => {
|
|
561
|
+
if (typeof attribute.name === "string" && attribute.name.length > 0 && typeof attribute.option === "string" && attribute.option.length > 0 && !Object.prototype.hasOwnProperty.call(options, attribute.name)) {
|
|
562
|
+
return { ...options, [attribute.name]: attribute.option };
|
|
563
|
+
}
|
|
564
|
+
return options;
|
|
565
|
+
}, {}),
|
|
438
566
|
sku: v.sku || void 0,
|
|
439
567
|
barcode: v.barcode || void 0,
|
|
440
568
|
prices: wooProductTraits.getPrices(v, context),
|
|
@@ -446,6 +574,11 @@ var wooProductTraits = {
|
|
|
446
574
|
prices: wooProductTraits.getPrices(doc, context),
|
|
447
575
|
stock: wooProductTraits.getStock(doc)
|
|
448
576
|
}],
|
|
577
|
+
getVariantOptions: (doc) => doc.type !== "variable" ? [] : (doc.attributes ?? []).filter((attribute) => attribute.variation === true && typeof attribute.name === "string" && attribute.name.length > 0).sort((a, b) => (typeof a.position === "number" ? a.position : Infinity) - (typeof b.position === "number" ? b.position : Infinity)).map((attribute) => ({
|
|
578
|
+
...Number.isInteger(attribute.id) && attribute.id > 0 ? { id: String(attribute.id) } : {},
|
|
579
|
+
name: attribute.name,
|
|
580
|
+
values: (attribute.options ?? []).filter((value) => typeof value === "string" && value.length > 0)
|
|
581
|
+
})),
|
|
449
582
|
isSellable: (doc) => doc.status === void 0 || doc.status === "publish",
|
|
450
583
|
getVariantCount: (doc) => doc.type === "variable" ? doc.variations?.length ?? 0 : 1,
|
|
451
584
|
getType: (doc) => doc.type ?? "simple",
|
|
@@ -616,6 +749,82 @@ function createWooReconcileFeed() {
|
|
|
616
749
|
return createReconcileFeed({ key: (doc) => doc.uuid, remote: (doc) => doc.id, fetchByIds: wooFetchByIds });
|
|
617
750
|
}
|
|
618
751
|
|
|
752
|
+
// src/reconcile/coupons.ts
|
|
753
|
+
import { createReconcileFeed as createReconcileFeed2 } from "@tallyui/core";
|
|
754
|
+
|
|
755
|
+
// src/replication/coupons.ts
|
|
756
|
+
function toCouponDocument(coupon) {
|
|
757
|
+
const uuid = wooProductUuid(coupon);
|
|
758
|
+
if (uuid === void 0) return void 0;
|
|
759
|
+
const projected = Object.fromEntries(Object.keys(wooCouponSchema.properties).filter((key) => Object.prototype.hasOwnProperty.call(coupon, key)).map((key) => [key, coupon[key]]));
|
|
760
|
+
if (Array.isArray(projected.used_by)) projected.used_by = projected.used_by.map(String);
|
|
761
|
+
return { ...projected, uuid, _deleted: coupon.status !== "publish" };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// src/reconcile/coupons.ts
|
|
765
|
+
var PAGE_SIZE2 = 100;
|
|
766
|
+
var LISTING_FIELDS2 = "id,date_modified_gmt,usage_count,used_by";
|
|
767
|
+
var wooCouponFingerprint = (c) => [c.date_modified_gmt ?? "", c.usage_count ?? "", JSON.stringify((c.used_by ?? []).map(String))].join("|");
|
|
768
|
+
async function get2(path, context) {
|
|
769
|
+
const response = await fetch(`${context.baseUrl}${path}`, {
|
|
770
|
+
headers: { ...context.headers, "Content-Type": "application/json" },
|
|
771
|
+
signal: context.signal
|
|
772
|
+
});
|
|
773
|
+
await checkResponse(response);
|
|
774
|
+
return response.json();
|
|
775
|
+
}
|
|
776
|
+
async function wooFetchCouponsByIds(entries, context) {
|
|
777
|
+
const ids = entries.map((e) => e.local?.id ?? e.remote).filter((id) => Number.isInteger(id));
|
|
778
|
+
const documents = [];
|
|
779
|
+
for (let i = 0; i < ids.length; i += MAX_IDS_PER_REQUEST) {
|
|
780
|
+
const chunk = ids.slice(i, i + MAX_IDS_PER_REQUEST);
|
|
781
|
+
const params = new URLSearchParams({ include: chunk.join(","), per_page: String(MAX_IDS_PER_REQUEST), status: "any" });
|
|
782
|
+
const rows = await get2(`/coupons?${params}`, context);
|
|
783
|
+
documents.push(...rows.map(toCouponDocument).filter((doc) => doc !== void 0));
|
|
784
|
+
}
|
|
785
|
+
return documents;
|
|
786
|
+
}
|
|
787
|
+
function createWooCouponFeed() {
|
|
788
|
+
return createReconcileFeed2({ key: (doc) => doc.uuid, remote: (doc) => doc.id, fetchByIds: wooFetchCouponsByIds });
|
|
789
|
+
}
|
|
790
|
+
var toEntries2 = (rows) => rows.filter((row) => Number.isInteger(row?.id)).map((row) => ({ key: String(row.id), fingerprint: wooCouponFingerprint(row), remote: row.id }));
|
|
791
|
+
function wooCouponReconcile(feed) {
|
|
792
|
+
return {
|
|
793
|
+
async *fetchPages(context, from = 1) {
|
|
794
|
+
for (let page = from; ; page++) {
|
|
795
|
+
const params = new URLSearchParams({
|
|
796
|
+
per_page: String(PAGE_SIZE2),
|
|
797
|
+
page: String(page),
|
|
798
|
+
orderby: "id",
|
|
799
|
+
order: "asc",
|
|
800
|
+
status: "publish",
|
|
801
|
+
_fields: LISTING_FIELDS2
|
|
802
|
+
});
|
|
803
|
+
const rows = await get2(`/coupons?${params}`, context);
|
|
804
|
+
yield { entries: toEntries2(rows), cursor: page + 1 };
|
|
805
|
+
if (rows.length < PAGE_SIZE2) return;
|
|
806
|
+
}
|
|
807
|
+
},
|
|
808
|
+
fingerprint: wooCouponFingerprint,
|
|
809
|
+
// The listing keys on the numeric id, never the till-local uuid.
|
|
810
|
+
matchKey: (doc) => Number.isInteger(doc.id) ? String(doc.id) : void 0,
|
|
811
|
+
async confirmGone(locals, context) {
|
|
812
|
+
const ids = locals.map((doc) => doc.id).filter((id) => Number.isInteger(id));
|
|
813
|
+
if (!ids.length) return [];
|
|
814
|
+
const live = /* @__PURE__ */ new Map();
|
|
815
|
+
for (let i = 0; i < ids.length; i += MAX_IDS_PER_REQUEST) {
|
|
816
|
+
const chunk = ids.slice(i, i + MAX_IDS_PER_REQUEST);
|
|
817
|
+
const params = new URLSearchParams({ include: chunk.join(","), per_page: String(MAX_IDS_PER_REQUEST), _fields: "id,meta_data" });
|
|
818
|
+
const rows = await get2(`/coupons?${params}`, context);
|
|
819
|
+
for (const row of rows) live.set(row.id, wooProductUuid(row));
|
|
820
|
+
}
|
|
821
|
+
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);
|
|
822
|
+
},
|
|
823
|
+
// Without an integer id, the feed would find nothing and tombstone without proof; proven tombstones pass.
|
|
824
|
+
enqueue: (entries) => feed.enqueue(entries.filter((e) => e.tombstone || Number.isInteger(e.local?.id) || Number.isInteger(e.remote)))
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
619
828
|
// src/customers.ts
|
|
620
829
|
import { CustomerServiceError } from "@tallyui/core";
|
|
621
830
|
function toWooCustomer(raw) {
|
|
@@ -721,6 +930,18 @@ function createWooCustomers(options) {
|
|
|
721
930
|
import { ConnectorUnauthorizedError as ConnectorUnauthorizedError2, StoreSettingsError, minorUnitDigits, woocommerceTax } from "@tallyui/core";
|
|
722
931
|
var WOO_ORDER_CREATE_VERSION = 5;
|
|
723
932
|
var WOO_BASE_ORDER_CREATE_VERSION = 3;
|
|
933
|
+
var WOO_COUPONS_MIN_PLUGIN = [1, 9, 0];
|
|
934
|
+
function wooPluginSupportsCoupons(version2) {
|
|
935
|
+
if (typeof version2 !== "string") return false;
|
|
936
|
+
const match = /^(\d+)\.(\d+)(?:\.(\d+))?(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.exec(version2.trim());
|
|
937
|
+
if (!match) return false;
|
|
938
|
+
const parts = [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)];
|
|
939
|
+
for (let index = 0; index < parts.length; index++) {
|
|
940
|
+
if (parts[index] > WOO_COUPONS_MIN_PLUGIN[index]) return true;
|
|
941
|
+
if (parts[index] < WOO_COUPONS_MIN_PLUGIN[index]) return false;
|
|
942
|
+
}
|
|
943
|
+
return !match[4];
|
|
944
|
+
}
|
|
724
945
|
async function wooStoreSettings(context) {
|
|
725
946
|
try {
|
|
726
947
|
const init = { method: "GET", headers: context.headers, signal: context.signal };
|
|
@@ -750,6 +971,7 @@ async function wooStoreSettings(context) {
|
|
|
750
971
|
currency,
|
|
751
972
|
pricesIncludeTax: store.prices_include_tax === "yes",
|
|
752
973
|
taxRoundAtSubtotal: store.tax_round_at_subtotal === "yes",
|
|
974
|
+
calcDiscountsSequentially: (store.woocommerce_calc_discounts_sequentially ?? store.calc_discounts_sequentially) === "yes",
|
|
753
975
|
...typeof store.shipping_tax_class === "string" ? { shippingTaxClass: store.shipping_tax_class } : {},
|
|
754
976
|
taxClassSlugs: classes.map((row) => row.slug),
|
|
755
977
|
taxRates,
|
|
@@ -831,11 +1053,26 @@ async function readWooCapabilities(context) {
|
|
|
831
1053
|
} catch (error) {
|
|
832
1054
|
if (error instanceof ConnectorUnauthorizedError2) throw error;
|
|
833
1055
|
}
|
|
1056
|
+
let coupons = false;
|
|
1057
|
+
try {
|
|
1058
|
+
const site = await fetch(`${context.baseUrl}/site`, {
|
|
1059
|
+
method: "GET",
|
|
1060
|
+
headers: context.headers,
|
|
1061
|
+
signal: context.signal
|
|
1062
|
+
});
|
|
1063
|
+
if (site.ok) {
|
|
1064
|
+
const body = await site.json();
|
|
1065
|
+
coupons = wooPluginSupportsCoupons(body?.wcpos_version);
|
|
1066
|
+
}
|
|
1067
|
+
} catch {
|
|
1068
|
+
coupons = false;
|
|
1069
|
+
}
|
|
834
1070
|
const orderCreate = capabilities.includes("order_create_v5") ? WOO_ORDER_CREATE_VERSION : WOO_BASE_ORDER_CREATE_VERSION;
|
|
835
1071
|
return {
|
|
836
1072
|
orderCreate,
|
|
837
1073
|
taxRounding: { granularity: "woocommerce", roundAtSubtotal: stores[0].tax_round_at_subtotal === "yes" },
|
|
838
1074
|
multiplePayments,
|
|
1075
|
+
coupons,
|
|
839
1076
|
// WCPOS v5 takes a fee's, shipping line's and custom line's tax_status and tax_class (orders #146, #161); it has no /tally/v1/info.
|
|
840
1077
|
lineTax: { none: true, classes: true }
|
|
841
1078
|
};
|
|
@@ -846,7 +1083,7 @@ async function readWooCapabilities(context) {
|
|
|
846
1083
|
}
|
|
847
1084
|
|
|
848
1085
|
// package.json
|
|
849
|
-
var version = "3.
|
|
1086
|
+
var version = "3.9.0";
|
|
850
1087
|
|
|
851
1088
|
// src/commands/transport.ts
|
|
852
1089
|
import { minorUnitDigits as minorUnitDigits2, moneyFromDecimalString, moneyFromMajor as moneyFromMajor2 } from "@tallyui/core";
|
|
@@ -1054,6 +1291,7 @@ var WooMissingTokenError = class extends ConnectorUnauthorizedError3 {
|
|
|
1054
1291
|
};
|
|
1055
1292
|
function createWooCommerceConnector() {
|
|
1056
1293
|
const catalogueFeed = createWooReconcileFeed();
|
|
1294
|
+
const couponFeed = createWooCouponFeed();
|
|
1057
1295
|
const customers = createWooCustomers();
|
|
1058
1296
|
return {
|
|
1059
1297
|
...wooConnectorParts,
|
|
@@ -1063,11 +1301,13 @@ function createWooCommerceConnector() {
|
|
|
1063
1301
|
// One replication per collection; the reconcile feed is last, so its fetch wins duplicates;
|
|
1064
1302
|
// duplicates are matched by uuid, the primary key, not the store id (#331). legacyKey
|
|
1065
1303
|
// reads an existing install's plain pull checkpoint as the product feed's, so it does not resync.
|
|
1066
|
-
products: combinePullAdapters({ products: wooProductReplication, reconcile: catalogueFeed.adapter }, { legacyKey: "products", key: (doc) => doc.uuid })
|
|
1304
|
+
products: combinePullAdapters({ products: wooProductReplication, reconcile: catalogueFeed.adapter }, { legacyKey: "products", key: (doc) => doc.uuid }),
|
|
1305
|
+
coupons: couponFeed.adapter
|
|
1067
1306
|
},
|
|
1068
1307
|
reconcile: {
|
|
1069
1308
|
// The feed's fetchByIds asks for at most this many ids per request; the runner budgets by requests.
|
|
1070
|
-
catalogue: { ...wooCatalogueReconcile(catalogueFeed), refetchBatchSize: MAX_IDS_PER_REQUEST }
|
|
1309
|
+
catalogue: { ...wooCatalogueReconcile(catalogueFeed), refetchBatchSize: MAX_IDS_PER_REQUEST },
|
|
1310
|
+
coupons: { ...wooCouponReconcile(couponFeed), refetchBatchSize: MAX_IDS_PER_REQUEST }
|
|
1071
1311
|
}
|
|
1072
1312
|
};
|
|
1073
1313
|
}
|
|
@@ -1111,7 +1351,8 @@ var wooConnectorParts = {
|
|
|
1111
1351
|
}
|
|
1112
1352
|
},
|
|
1113
1353
|
schemas: {
|
|
1114
|
-
products: wooProductSchema
|
|
1354
|
+
products: wooProductSchema,
|
|
1355
|
+
coupons: wooCouponSchema
|
|
1115
1356
|
},
|
|
1116
1357
|
traits: {
|
|
1117
1358
|
product: wooProductTraits
|
|
@@ -1131,12 +1372,17 @@ export {
|
|
|
1131
1372
|
WooTokenRefusedError,
|
|
1132
1373
|
createWooCommandTransport,
|
|
1133
1374
|
createWooCommerceConnector,
|
|
1375
|
+
createWooCouponFeed,
|
|
1134
1376
|
createWooCustomers,
|
|
1135
1377
|
readWooCapabilities,
|
|
1378
|
+
toCouponDocument,
|
|
1136
1379
|
toWooCustomer,
|
|
1137
1380
|
toWooOrderPayload,
|
|
1138
1381
|
wcposClientPart,
|
|
1139
1382
|
wooCatalogueReconcile,
|
|
1383
|
+
wooCouponFingerprint,
|
|
1384
|
+
wooCouponReconcile,
|
|
1385
|
+
wooCouponSchema,
|
|
1140
1386
|
wooProductReplication,
|
|
1141
1387
|
wooProductSchema,
|
|
1142
1388
|
wooProductSync,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tallyui/connector-woocommerce",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.9.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "WooCommerce connector for Tally UI",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
".": {
|
|
11
11
|
"types": "./dist/index.d.ts",
|
|
12
12
|
"import": "./dist/index.js",
|
|
13
|
-
"source": "./src/index.ts"
|
|
13
|
+
"source": "./src/index.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
14
15
|
}
|
|
15
16
|
},
|
|
16
17
|
"files": [
|
|
@@ -26,10 +27,10 @@
|
|
|
26
27
|
"directory": "connectors/woocommerce"
|
|
27
28
|
},
|
|
28
29
|
"peerDependencies": {
|
|
29
|
-
"@tallyui/core": "^3.
|
|
30
|
+
"@tallyui/core": "^3.9.0"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
|
-
"@tallyui/database": "3.
|
|
33
|
+
"@tallyui/database": "3.9.0",
|
|
33
34
|
"rxdb": "17.5.0"
|
|
34
35
|
},
|
|
35
36
|
"scripts": {
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { combinePullAdapters, ConnectorUnauthorizedError, type TallyConnector } from '@tallyui/core';
|
|
2
2
|
|
|
3
3
|
import { wooProductSchema } from './schemas/products';
|
|
4
|
+
import { wooCouponSchema } from './schemas/coupons';
|
|
4
5
|
import { wooProductTraits } from './traits/product';
|
|
5
6
|
import { wooProductSync } from './sync/products';
|
|
6
7
|
import { wooProductReplication } from './replication/products';
|
|
7
8
|
import { wooCatalogueReconcile } from './reconcile/catalogue';
|
|
8
9
|
import { createWooReconcileFeed, MAX_IDS_PER_REQUEST } from './reconcile/feed';
|
|
10
|
+
import { createWooCouponFeed, wooCouponReconcile } from './reconcile/coupons';
|
|
9
11
|
import { createWooCustomers } from './customers';
|
|
10
12
|
import { wooStoreSettings, readWooCapabilities } from './store-settings';
|
|
11
13
|
import { version } from '../package.json';
|
|
@@ -43,6 +45,7 @@ export class WooMissingTokenError extends ConnectorUnauthorizedError {
|
|
|
43
45
|
export function createWooCommerceConnector(): TallyConnector {
|
|
44
46
|
// The catalogue reconcile's corrections reach `products` only through this pull adapter (#248).
|
|
45
47
|
const catalogueFeed = createWooReconcileFeed();
|
|
48
|
+
const couponFeed = createWooCouponFeed();
|
|
46
49
|
const customers = createWooCustomers();
|
|
47
50
|
return {
|
|
48
51
|
...wooConnectorParts,
|
|
@@ -53,10 +56,12 @@ export function createWooCommerceConnector(): TallyConnector {
|
|
|
53
56
|
// duplicates are matched by uuid, the primary key, not the store id (#331). legacyKey
|
|
54
57
|
// reads an existing install's plain pull checkpoint as the product feed's, so it does not resync.
|
|
55
58
|
products: combinePullAdapters({ products: wooProductReplication, reconcile: catalogueFeed.adapter }, { legacyKey: 'products', key: (doc: any) => doc.uuid }),
|
|
59
|
+
coupons: couponFeed.adapter,
|
|
56
60
|
},
|
|
57
61
|
reconcile: {
|
|
58
62
|
// The feed's fetchByIds asks for at most this many ids per request; the runner budgets by requests.
|
|
59
63
|
catalogue: { ...wooCatalogueReconcile(catalogueFeed), refetchBatchSize: MAX_IDS_PER_REQUEST },
|
|
64
|
+
coupons: { ...wooCouponReconcile(couponFeed), refetchBatchSize: MAX_IDS_PER_REQUEST },
|
|
60
65
|
},
|
|
61
66
|
};
|
|
62
67
|
}
|
|
@@ -104,6 +109,7 @@ const wooConnectorParts = {
|
|
|
104
109
|
|
|
105
110
|
schemas: {
|
|
106
111
|
products: wooProductSchema,
|
|
112
|
+
coupons: wooCouponSchema,
|
|
107
113
|
},
|
|
108
114
|
|
|
109
115
|
traits: {
|
|
@@ -127,6 +133,9 @@ export { createWooCustomers, toWooCustomer } from './customers';
|
|
|
127
133
|
export { createWooCommandTransport, toWooOrderPayload, type WooCommandTransportOptions } from './commands/transport';
|
|
128
134
|
export { ConnectorUnauthorizedError } from '@tallyui/core';
|
|
129
135
|
export { wooProductSchema } from './schemas/products';
|
|
136
|
+
export { wooCouponSchema } from './schemas/coupons';
|
|
137
|
+
export { toCouponDocument } from './replication/coupons';
|
|
138
|
+
export { wooCouponReconcile, wooCouponFingerprint, createWooCouponFeed } from './reconcile/coupons';
|
|
130
139
|
export { wooProductTraits } from './traits/product';
|
|
131
140
|
export { wooProductSync } from './sync/products';
|
|
132
141
|
export { wooProductReplication, WooDateFilterError, WooTokenRefusedError, WooMissingUuidError, WooTillUpdateRequiredError } from './replication/products';
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createReconcileFeed, type CatalogueReconcileAdapter, type CatalogueReconcileEntry, type ReconcileFeed, type ReconcileFetchEntry, type SyncContext } from '@tallyui/core';
|
|
2
|
+
import { toCouponDocument } from '../replication/coupons';
|
|
3
|
+
import { checkResponse, wooProductUuid } from '../replication/products';
|
|
4
|
+
import { MAX_IDS_PER_REQUEST } from './feed';
|
|
5
|
+
|
|
6
|
+
/** The listing's page size, WordPress's per_page maximum. */
|
|
7
|
+
const PAGE_SIZE = 100;
|
|
8
|
+
/** The numeric id for refetching, and the fingerprint's fields. */
|
|
9
|
+
const LISTING_FIELDS = 'id,date_modified_gmt,usage_count,used_by';
|
|
10
|
+
|
|
11
|
+
/** Coupon usage never moves the modified time (ADR-077 amendment 2); numeric used_by ids match stored strings. */
|
|
12
|
+
export const wooCouponFingerprint = (c: { date_modified_gmt?: string | null; usage_count?: number | null; used_by?: readonly unknown[] | null }): string =>
|
|
13
|
+
[c.date_modified_gmt ?? '', c.usage_count ?? '', JSON.stringify((c.used_by ?? []).map(String))].join('|');
|
|
14
|
+
|
|
15
|
+
async function get(path: string, context: SyncContext): Promise<any> {
|
|
16
|
+
const response = await fetch(`${context.baseUrl}${path}`, {
|
|
17
|
+
headers: { ...context.headers, 'Content-Type': 'application/json' },
|
|
18
|
+
signal: context.signal,
|
|
19
|
+
});
|
|
20
|
+
await checkResponse(response);
|
|
21
|
+
return response.json();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Full coupons by numeric id (the till's own id, else the listing's remote). A draft arrives deleted;
|
|
26
|
+
* a trashed coupon is absent from status=any, so the feed tombstones its local copy.
|
|
27
|
+
*/
|
|
28
|
+
export async function wooFetchCouponsByIds(entries: Array<ReconcileFetchEntry<any>>, context: SyncContext): Promise<any[]> {
|
|
29
|
+
const ids = entries.map((e) => e.local?.id ?? e.remote).filter((id): id is number => Number.isInteger(id));
|
|
30
|
+
const documents: any[] = [];
|
|
31
|
+
for (let i = 0; i < ids.length; i += MAX_IDS_PER_REQUEST) {
|
|
32
|
+
const chunk = ids.slice(i, i + MAX_IDS_PER_REQUEST);
|
|
33
|
+
const params = new URLSearchParams({ include: chunk.join(','), per_page: String(MAX_IDS_PER_REQUEST), status: 'any' });
|
|
34
|
+
const rows: any[] = await get(`/coupons?${params}`, context);
|
|
35
|
+
documents.push(...rows.map(toCouponDocument).filter((doc) => doc !== undefined));
|
|
36
|
+
}
|
|
37
|
+
return documents;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Coupons reach the collection only through this feed, keyed by uuid and matched to new listing entries by id. */
|
|
41
|
+
export function createWooCouponFeed(): ReconcileFeed<any> {
|
|
42
|
+
return createReconcileFeed<any>({ key: (doc) => doc.uuid, remote: (doc) => doc.id, fetchByIds: wooFetchCouponsByIds });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Listing rows keyed by numeric id; a row without an integer id cannot be asked about, and is dropped. */
|
|
46
|
+
const toEntries = (rows: any[]) => rows.filter((row) => Number.isInteger(row?.id))
|
|
47
|
+
.map((row): CatalogueReconcileEntry => ({ key: String(row.id), fingerprint: wooCouponFingerprint(row), remote: row.id }));
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The coupons' only source (ADR-077 amendment 2): page through published coupons, with no date filter,
|
|
51
|
+
* and hand differences to the feed. confirmGone proves deletion by re-reading the published listing by id.
|
|
52
|
+
*/
|
|
53
|
+
export function wooCouponReconcile(feed: Pick<ReconcileFeed<any>, 'enqueue'>): CatalogueReconcileAdapter<any, number> {
|
|
54
|
+
return {
|
|
55
|
+
async *fetchPages(context, from = 1) {
|
|
56
|
+
for (let page = from; ; page++) {
|
|
57
|
+
const params = new URLSearchParams({
|
|
58
|
+
per_page: String(PAGE_SIZE), page: String(page), orderby: 'id', order: 'asc', status: 'publish', _fields: LISTING_FIELDS,
|
|
59
|
+
});
|
|
60
|
+
const rows: any[] = await get(`/coupons?${params}`, context);
|
|
61
|
+
yield { entries: toEntries(rows), cursor: page + 1 };
|
|
62
|
+
if (rows.length < PAGE_SIZE) return;
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
fingerprint: wooCouponFingerprint,
|
|
66
|
+
// The listing keys on the numeric id, never the till-local uuid.
|
|
67
|
+
matchKey: (doc) => (Number.isInteger(doc.id) ? String(doc.id) : undefined),
|
|
68
|
+
async confirmGone(locals, context) {
|
|
69
|
+
const ids = locals.map((doc) => doc.id).filter((id) => Number.isInteger(id));
|
|
70
|
+
// An empty include= would list the whole store; a local without a numeric id has no proof and is kept.
|
|
71
|
+
if (!ids.length) return [];
|
|
72
|
+
const live = new Map<number, string | undefined>();
|
|
73
|
+
for (let i = 0; i < ids.length; i += MAX_IDS_PER_REQUEST) {
|
|
74
|
+
const chunk = ids.slice(i, i + MAX_IDS_PER_REQUEST);
|
|
75
|
+
const params = new URLSearchParams({ include: chunk.join(','), per_page: String(MAX_IDS_PER_REQUEST), _fields: 'id,meta_data' });
|
|
76
|
+
const rows: any[] = await get(`/coupons?${params}`, context);
|
|
77
|
+
for (const row of rows) live.set(row.id, wooProductUuid(row));
|
|
78
|
+
}
|
|
79
|
+
// A live id backs only the store's uuid (#369); a live row without a uuid keeps the local copy.
|
|
80
|
+
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);
|
|
81
|
+
},
|
|
82
|
+
// Without an integer id, the feed would find nothing and tombstone without proof; proven tombstones pass.
|
|
83
|
+
enqueue: (entries) => feed.enqueue(entries.filter((e) => e.tombstone || Number.isInteger(e.local?.id) || Number.isInteger(e.remote))),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { wooCouponSchema } from '../schemas/coupons';
|
|
2
|
+
import { wooProductUuid } from './products';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One coupon row as the till stores it: a coupon without a uuid cannot be keyed and is skipped, never thrown,
|
|
6
|
+
* so one bad row cannot stall the coupons' pull. A coupon that is not published arrives deleted.
|
|
7
|
+
*/
|
|
8
|
+
export function toCouponDocument(coupon: any): any | undefined {
|
|
9
|
+
const uuid = wooProductUuid(coupon);
|
|
10
|
+
if (uuid === undefined) return undefined;
|
|
11
|
+
const projected = Object.fromEntries(Object.keys(wooCouponSchema.properties)
|
|
12
|
+
.filter((key) => Object.prototype.hasOwnProperty.call(coupon, key))
|
|
13
|
+
.map((key) => [key, coupon[key]]));
|
|
14
|
+
if (Array.isArray(projected.used_by)) projected.used_by = projected.used_by.map(String);
|
|
15
|
+
return { ...projected, uuid, _deleted: coupon.status !== 'publish' };
|
|
16
|
+
}
|
|
@@ -90,7 +90,9 @@ export function toVariationDocument(payload: any) {
|
|
|
90
90
|
id, sku, barcode: wooProductBarcode(payload), price, regular_price, sale_price, on_sale,
|
|
91
91
|
stock_status, stock_quantity, manage_stock, status, purchasable,
|
|
92
92
|
tax_class, tax_status,
|
|
93
|
-
attributes: (payload.attributes ?? []).map(({ name, option }: any) => ({
|
|
93
|
+
attributes: (payload.attributes ?? []).map(({ id, name, option }: any) => ({
|
|
94
|
+
...(typeof id === 'number' ? { id } : {}), name, option,
|
|
95
|
+
})),
|
|
94
96
|
}).filter(([, value]) => value !== undefined));
|
|
95
97
|
}
|
|
96
98
|
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { RxJsonSchema } from 'rxdb';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WooCommerce Coupon RxDB schema (ADR-077 amendment 2).
|
|
5
|
+
* Mirrors the coupon listing; the catalogue reconcile is its only source.
|
|
6
|
+
*/
|
|
7
|
+
export const wooCouponSchema: RxJsonSchema<any> = {
|
|
8
|
+
title: 'WooCommerce Coupon',
|
|
9
|
+
version: 0,
|
|
10
|
+
type: 'object',
|
|
11
|
+
primaryKey: 'uuid',
|
|
12
|
+
properties: {
|
|
13
|
+
uuid: {
|
|
14
|
+
type: 'string',
|
|
15
|
+
maxLength: 36,
|
|
16
|
+
},
|
|
17
|
+
id: {
|
|
18
|
+
type: 'integer',
|
|
19
|
+
multipleOf: 1,
|
|
20
|
+
minimum: 0,
|
|
21
|
+
maximum: 2147483647,
|
|
22
|
+
},
|
|
23
|
+
code: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
maxLength: 255,
|
|
26
|
+
},
|
|
27
|
+
status: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
},
|
|
30
|
+
amount: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
},
|
|
33
|
+
discount_type: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
},
|
|
36
|
+
description: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
},
|
|
39
|
+
date_created: {
|
|
40
|
+
type: 'string',
|
|
41
|
+
},
|
|
42
|
+
date_created_gmt: {
|
|
43
|
+
type: 'string',
|
|
44
|
+
},
|
|
45
|
+
date_modified: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
},
|
|
48
|
+
date_modified_gmt: {
|
|
49
|
+
type: 'string',
|
|
50
|
+
},
|
|
51
|
+
minimum_amount: {
|
|
52
|
+
type: 'string',
|
|
53
|
+
},
|
|
54
|
+
maximum_amount: {
|
|
55
|
+
type: 'string',
|
|
56
|
+
},
|
|
57
|
+
date_expires: {
|
|
58
|
+
type: ['string', 'null'],
|
|
59
|
+
},
|
|
60
|
+
date_expires_gmt: {
|
|
61
|
+
type: ['string', 'null'],
|
|
62
|
+
},
|
|
63
|
+
usage_count: {
|
|
64
|
+
type: 'integer',
|
|
65
|
+
},
|
|
66
|
+
usage_limit: {
|
|
67
|
+
type: ['integer', 'null'],
|
|
68
|
+
},
|
|
69
|
+
usage_limit_per_user: {
|
|
70
|
+
type: ['integer', 'null'],
|
|
71
|
+
},
|
|
72
|
+
limit_usage_to_x_items: {
|
|
73
|
+
type: ['integer', 'null'],
|
|
74
|
+
},
|
|
75
|
+
individual_use: {
|
|
76
|
+
type: 'boolean',
|
|
77
|
+
},
|
|
78
|
+
free_shipping: {
|
|
79
|
+
type: 'boolean',
|
|
80
|
+
},
|
|
81
|
+
exclude_sale_items: {
|
|
82
|
+
type: 'boolean',
|
|
83
|
+
},
|
|
84
|
+
product_ids: {
|
|
85
|
+
type: 'array',
|
|
86
|
+
items: { type: 'integer' },
|
|
87
|
+
},
|
|
88
|
+
excluded_product_ids: {
|
|
89
|
+
type: 'array',
|
|
90
|
+
items: { type: 'integer' },
|
|
91
|
+
},
|
|
92
|
+
product_categories: {
|
|
93
|
+
type: 'array',
|
|
94
|
+
items: { type: 'integer' },
|
|
95
|
+
},
|
|
96
|
+
excluded_product_categories: {
|
|
97
|
+
type: 'array',
|
|
98
|
+
items: { type: 'integer' },
|
|
99
|
+
},
|
|
100
|
+
email_restrictions: {
|
|
101
|
+
type: 'array',
|
|
102
|
+
items: { type: 'string' },
|
|
103
|
+
},
|
|
104
|
+
used_by: {
|
|
105
|
+
type: 'array',
|
|
106
|
+
items: { type: 'string' },
|
|
107
|
+
},
|
|
108
|
+
meta_data: {
|
|
109
|
+
type: 'array',
|
|
110
|
+
items: {
|
|
111
|
+
type: 'object',
|
|
112
|
+
properties: {
|
|
113
|
+
id: { type: 'integer' },
|
|
114
|
+
key: { type: 'string' },
|
|
115
|
+
value: {},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
indexes: ['code'],
|
|
121
|
+
required: ['uuid'],
|
|
122
|
+
};
|
package/src/store-settings.ts
CHANGED
|
@@ -5,6 +5,20 @@ import type { ServerCapabilities, StoreSettings, SyncContext } from '@tallyui/co
|
|
|
5
5
|
export const WOO_ORDER_CREATE_VERSION = 5;
|
|
6
6
|
// The order.create version a store gets unless /status lists order_create_v5, which only the TallyUI fork of the plugin advertises.
|
|
7
7
|
const WOO_BASE_ORDER_CREATE_VERSION = 3;
|
|
8
|
+
// The first woocommerce-pos release whose coupon recalculation keeps the POS price (ADR-077 amendment 4, R2: 1.9.0, 2026-05-15).
|
|
9
|
+
const WOO_COUPONS_MIN_PLUGIN: readonly [number, number, number] = [1, 9, 0];
|
|
10
|
+
|
|
11
|
+
export function wooPluginSupportsCoupons(version: unknown): boolean {
|
|
12
|
+
if (typeof version !== 'string') return false;
|
|
13
|
+
const match = /^(\d+)\.(\d+)(?:\.(\d+))?(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.exec(version.trim());
|
|
14
|
+
if (!match) return false;
|
|
15
|
+
const parts: readonly [number, number, number] = [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)];
|
|
16
|
+
for (let index = 0; index < parts.length; index++) {
|
|
17
|
+
if (parts[index] > WOO_COUPONS_MIN_PLUGIN[index]) return true;
|
|
18
|
+
if (parts[index] < WOO_COUPONS_MIN_PLUGIN[index]) return false;
|
|
19
|
+
}
|
|
20
|
+
return !match[4];
|
|
21
|
+
}
|
|
8
22
|
|
|
9
23
|
export async function wooStoreSettings(context: SyncContext): Promise<StoreSettings> {
|
|
10
24
|
try {
|
|
@@ -38,6 +52,7 @@ export async function wooStoreSettings(context: SyncContext): Promise<StoreSetti
|
|
|
38
52
|
currency,
|
|
39
53
|
pricesIncludeTax: store.prices_include_tax === 'yes',
|
|
40
54
|
taxRoundAtSubtotal: store.tax_round_at_subtotal === 'yes',
|
|
55
|
+
calcDiscountsSequentially: (store.woocommerce_calc_discounts_sequentially ?? store.calc_discounts_sequentially) === 'yes',
|
|
41
56
|
...(typeof store.shipping_tax_class === 'string' ? { shippingTaxClass: store.shipping_tax_class } : {}),
|
|
42
57
|
taxClassSlugs: classes.map((row) => row.slug),
|
|
43
58
|
taxRates,
|
|
@@ -118,11 +133,24 @@ export async function readWooCapabilities(context: SyncContext): Promise<ServerC
|
|
|
118
133
|
} catch (error) {
|
|
119
134
|
if (error instanceof ConnectorUnauthorizedError) throw error;
|
|
120
135
|
}
|
|
136
|
+
let coupons = false;
|
|
137
|
+
try {
|
|
138
|
+
const site = await fetch(`${context.baseUrl}/site`, {
|
|
139
|
+
method: 'GET', headers: context.headers, signal: context.signal,
|
|
140
|
+
});
|
|
141
|
+
if (site.ok) {
|
|
142
|
+
const body = await site.json();
|
|
143
|
+
coupons = wooPluginSupportsCoupons(body?.wcpos_version);
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
coupons = false;
|
|
147
|
+
}
|
|
121
148
|
const orderCreate = capabilities.includes('order_create_v5') ? WOO_ORDER_CREATE_VERSION : WOO_BASE_ORDER_CREATE_VERSION;
|
|
122
149
|
return {
|
|
123
150
|
orderCreate,
|
|
124
151
|
taxRounding: { granularity: 'woocommerce', roundAtSubtotal: stores[0].tax_round_at_subtotal === 'yes' },
|
|
125
152
|
multiplePayments,
|
|
153
|
+
coupons,
|
|
126
154
|
// WCPOS v5 takes a fee's, shipping line's and custom line's tax_status and tax_class (orders #146, #161); it has no /tally/v1/info.
|
|
127
155
|
lineTax: { none: true, classes: true },
|
|
128
156
|
};
|
package/src/traits/product.ts
CHANGED
|
@@ -85,6 +85,14 @@ export const wooProductTraits: ProductTraits = {
|
|
|
85
85
|
.filter((v: any) => v.status === 'publish' && v.purchasable !== false).map((v: any) => ({
|
|
86
86
|
id: String(v.id),
|
|
87
87
|
title: (v.attributes ?? []).map((attribute: any) => attribute.option).join(' / ') || undefined,
|
|
88
|
+
options: (v.attributes ?? []).reduce((options: Record<string, string>, attribute: any) => {
|
|
89
|
+
if (typeof attribute.name === 'string' && attribute.name.length > 0
|
|
90
|
+
&& typeof attribute.option === 'string' && attribute.option.length > 0
|
|
91
|
+
&& !Object.prototype.hasOwnProperty.call(options, attribute.name)) {
|
|
92
|
+
return { ...options, [attribute.name]: attribute.option };
|
|
93
|
+
}
|
|
94
|
+
return options;
|
|
95
|
+
}, {}),
|
|
88
96
|
sku: v.sku || undefined,
|
|
89
97
|
barcode: v.barcode || undefined,
|
|
90
98
|
prices: wooProductTraits.getPrices(v, context),
|
|
@@ -97,6 +105,17 @@ export const wooProductTraits: ProductTraits = {
|
|
|
97
105
|
stock: wooProductTraits.getStock(doc),
|
|
98
106
|
}],
|
|
99
107
|
|
|
108
|
+
getVariantOptions: (doc) => doc.type !== 'variable' ? [] : (doc.attributes ?? [])
|
|
109
|
+
.filter((attribute: any) => attribute.variation === true
|
|
110
|
+
&& typeof attribute.name === 'string' && attribute.name.length > 0)
|
|
111
|
+
.sort((a: any, b: any) => (typeof a.position === 'number' ? a.position : Infinity)
|
|
112
|
+
- (typeof b.position === 'number' ? b.position : Infinity))
|
|
113
|
+
.map((attribute: any) => ({
|
|
114
|
+
...(Number.isInteger(attribute.id) && attribute.id > 0 ? { id: String(attribute.id) } : {}),
|
|
115
|
+
name: attribute.name,
|
|
116
|
+
values: (attribute.options ?? []).filter((value: any) => typeof value === 'string' && value.length > 0),
|
|
117
|
+
})),
|
|
118
|
+
|
|
100
119
|
isSellable: (doc) => doc.status === undefined || doc.status === 'publish',
|
|
101
120
|
|
|
102
121
|
getVariantCount: (doc) => doc.type === 'variable' ? doc.variations?.length ?? 0 : 1,
|