@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 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, ProductTraits, CollectionSync, ReplicationAdapter, ReconcileFeed, CatalogueReconcileAdapter, ConnectorUnauthorizedError, TallyConnector } from '@tallyui/core';
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 }) => ({ 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.7.1";
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.7.1",
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.7.1"
30
+ "@tallyui/core": "^3.9.0"
30
31
  },
31
32
  "devDependencies": {
32
- "@tallyui/database": "3.7.1",
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) => ({ name, option })),
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
+ };
@@ -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
  };
@@ -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,