@tallyui/connector-woocommerce 3.8.0 → 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
 
@@ -631,6 +749,82 @@ function createWooReconcileFeed() {
631
749
  return createReconcileFeed({ key: (doc) => doc.uuid, remote: (doc) => doc.id, fetchByIds: wooFetchByIds });
632
750
  }
633
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
+
634
828
  // src/customers.ts
635
829
  import { CustomerServiceError } from "@tallyui/core";
636
830
  function toWooCustomer(raw) {
@@ -736,6 +930,18 @@ function createWooCustomers(options) {
736
930
  import { ConnectorUnauthorizedError as ConnectorUnauthorizedError2, StoreSettingsError, minorUnitDigits, woocommerceTax } from "@tallyui/core";
737
931
  var WOO_ORDER_CREATE_VERSION = 5;
738
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
+ }
739
945
  async function wooStoreSettings(context) {
740
946
  try {
741
947
  const init = { method: "GET", headers: context.headers, signal: context.signal };
@@ -765,6 +971,7 @@ async function wooStoreSettings(context) {
765
971
  currency,
766
972
  pricesIncludeTax: store.prices_include_tax === "yes",
767
973
  taxRoundAtSubtotal: store.tax_round_at_subtotal === "yes",
974
+ calcDiscountsSequentially: (store.woocommerce_calc_discounts_sequentially ?? store.calc_discounts_sequentially) === "yes",
768
975
  ...typeof store.shipping_tax_class === "string" ? { shippingTaxClass: store.shipping_tax_class } : {},
769
976
  taxClassSlugs: classes.map((row) => row.slug),
770
977
  taxRates,
@@ -846,11 +1053,26 @@ async function readWooCapabilities(context) {
846
1053
  } catch (error) {
847
1054
  if (error instanceof ConnectorUnauthorizedError2) throw error;
848
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
+ }
849
1070
  const orderCreate = capabilities.includes("order_create_v5") ? WOO_ORDER_CREATE_VERSION : WOO_BASE_ORDER_CREATE_VERSION;
850
1071
  return {
851
1072
  orderCreate,
852
1073
  taxRounding: { granularity: "woocommerce", roundAtSubtotal: stores[0].tax_round_at_subtotal === "yes" },
853
1074
  multiplePayments,
1075
+ coupons,
854
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.
855
1077
  lineTax: { none: true, classes: true }
856
1078
  };
@@ -861,7 +1083,7 @@ async function readWooCapabilities(context) {
861
1083
  }
862
1084
 
863
1085
  // package.json
864
- var version = "3.8.0";
1086
+ var version = "3.9.0";
865
1087
 
866
1088
  // src/commands/transport.ts
867
1089
  import { minorUnitDigits as minorUnitDigits2, moneyFromDecimalString, moneyFromMajor as moneyFromMajor2 } from "@tallyui/core";
@@ -1069,6 +1291,7 @@ var WooMissingTokenError = class extends ConnectorUnauthorizedError3 {
1069
1291
  };
1070
1292
  function createWooCommerceConnector() {
1071
1293
  const catalogueFeed = createWooReconcileFeed();
1294
+ const couponFeed = createWooCouponFeed();
1072
1295
  const customers = createWooCustomers();
1073
1296
  return {
1074
1297
  ...wooConnectorParts,
@@ -1078,11 +1301,13 @@ function createWooCommerceConnector() {
1078
1301
  // One replication per collection; the reconcile feed is last, so its fetch wins duplicates;
1079
1302
  // duplicates are matched by uuid, the primary key, not the store id (#331). legacyKey
1080
1303
  // reads an existing install's plain pull checkpoint as the product feed's, so it does not resync.
1081
- 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
1082
1306
  },
1083
1307
  reconcile: {
1084
1308
  // The feed's fetchByIds asks for at most this many ids per request; the runner budgets by requests.
1085
- 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 }
1086
1311
  }
1087
1312
  };
1088
1313
  }
@@ -1126,7 +1351,8 @@ var wooConnectorParts = {
1126
1351
  }
1127
1352
  },
1128
1353
  schemas: {
1129
- products: wooProductSchema
1354
+ products: wooProductSchema,
1355
+ coupons: wooCouponSchema
1130
1356
  },
1131
1357
  traits: {
1132
1358
  product: wooProductTraits
@@ -1146,12 +1372,17 @@ export {
1146
1372
  WooTokenRefusedError,
1147
1373
  createWooCommandTransport,
1148
1374
  createWooCommerceConnector,
1375
+ createWooCouponFeed,
1149
1376
  createWooCustomers,
1150
1377
  readWooCapabilities,
1378
+ toCouponDocument,
1151
1379
  toWooCustomer,
1152
1380
  toWooOrderPayload,
1153
1381
  wcposClientPart,
1154
1382
  wooCatalogueReconcile,
1383
+ wooCouponFingerprint,
1384
+ wooCouponReconcile,
1385
+ wooCouponSchema,
1155
1386
  wooProductReplication,
1156
1387
  wooProductSchema,
1157
1388
  wooProductSync,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/connector-woocommerce",
3
- "version": "3.8.0",
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.8.0"
30
+ "@tallyui/core": "^3.9.0"
30
31
  },
31
32
  "devDependencies": {
32
- "@tallyui/database": "3.8.0",
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
+ }
@@ -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
  };