@tallyui/connector-woocommerce 3.2.0 → 3.3.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/dist/index.d.ts CHANGED
@@ -1,7 +1,12 @@
1
- import { SyncContext, Customer, CustomerInput, OrderCreateEnvelope, CommandResult, ProductTraits, CollectionSync, ReplicationAdapter, ReconcileFeed, CatalogueReconcileAdapter, ConnectorUnauthorizedError, TallyConnector } from '@tallyui/core';
1
+ import { SyncContext, ServerCapabilities, StoreSettings, Customer, CustomerInput, OrderCreateEnvelope, CommandResult, ProductTraits, CollectionSync, ReplicationAdapter, ReconcileFeed, CatalogueReconcileAdapter, ConnectorUnauthorizedError, TallyConnector } from '@tallyui/core';
2
2
  export { ConnectorUnauthorizedError } from '@tallyui/core';
3
3
  import { RxJsonSchema } from 'rxdb';
4
4
 
5
+ /** The highest order.create version the WooCommerce transport maps: discounts and the customer (v3). It rises when the transport maps split payments (v4) and fees, shipping and custom lines (v5). */
6
+ declare const WOO_ORDER_CREATE_VERSION = 3;
7
+ declare function wooStoreSettings(context: SyncContext): Promise<StoreSettings>;
8
+ declare function readWooCapabilities(context: SyncContext): Promise<ServerCapabilities | undefined>;
9
+
5
10
  type WooCustomerRow = {
6
11
  id: number | string;
7
12
  first_name?: string | null;
@@ -48,8 +53,19 @@ interface WooCommandTransportOptions {
48
53
  getHeaders: () => Record<string, string> | Promise<Record<string, string>>;
49
54
  fetch?: typeof fetch;
50
55
  timeoutMs?: number;
56
+ /** True when the store's `/status` lists `order_payments_list` (`ServerCapabilities.multiplePayments`): an order may
57
+ * then carry several payments, recorded in `_woocommerce_pos_payments`. */
58
+ acceptsPaymentsList?: boolean | (() => boolean | Promise<boolean>);
59
+ }
60
+ interface WooLocalOrder {
61
+ lines: ReadonlyArray<{
62
+ id: string;
63
+ netMicros?: string;
64
+ }>;
51
65
  }
52
- declare function toWooOrderPayload(envelope: OrderCreateEnvelope): {
66
+ declare function toWooOrderPayload(envelope: OrderCreateEnvelope, local?: WooLocalOrder, options?: {
67
+ paymentsList?: boolean;
68
+ }): {
53
69
  payload: Record<string, unknown>;
54
70
  } | {
55
71
  error: {
@@ -58,7 +74,11 @@ declare function toWooOrderPayload(envelope: OrderCreateEnvelope): {
58
74
  };
59
75
  };
60
76
  declare function createWooCommandTransport(options: WooCommandTransportOptions): {
61
- send(batch: OrderCreateEnvelope[]): Promise<WooTransportOutcome>;
77
+ send(batch: OrderCreateEnvelope[], context?: {
78
+ local?: {
79
+ orders?: ReadonlyMap<string, WooLocalOrder>;
80
+ };
81
+ }): Promise<WooTransportOutcome>;
62
82
  };
63
83
 
64
84
  /**
@@ -198,4 +218,4 @@ declare function createWooCommerceConnector(): TallyConnector;
198
218
  */
199
219
  declare const woocommerceConnector: TallyConnector;
200
220
 
201
- export { type WooCommandTransportOptions, WooDateFilterError, WooMissingTokenError, WooMissingUuidError, WooTillUpdateRequiredError, WooTokenRefusedError, createWooCommandTransport, createWooCommerceConnector, createWooCustomers, toWooCustomer, toWooOrderPayload, wcposClientPart, wooCatalogueReconcile, wooProductReplication, wooProductSchema, wooProductSync, wooProductTraits, wooReconcileFingerprint, woocommerceConnector };
221
+ 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 };
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // src/index.ts
2
- import { combinePullAdapters, ConnectorUnauthorizedError as ConnectorUnauthorizedError2 } from "@tallyui/core";
2
+ import { combinePullAdapters, ConnectorUnauthorizedError as ConnectorUnauthorizedError3 } from "@tallyui/core";
3
3
 
4
4
  // src/schemas/products.ts
5
5
  var wooProductSchema = {
6
6
  title: "WooCommerce Product",
7
- version: 1,
7
+ version: 2,
8
8
  type: "object",
9
9
  primaryKey: "uuid",
10
10
  properties: {
@@ -50,6 +50,12 @@ var wooProductSchema = {
50
50
  sale_price: {
51
51
  type: "string"
52
52
  },
53
+ tax_class: {
54
+ type: "string"
55
+ },
56
+ tax_status: {
57
+ type: "string"
58
+ },
53
59
  on_sale: {
54
60
  type: "boolean"
55
61
  },
@@ -135,6 +141,8 @@ var wooProductSchema = {
135
141
  price: { type: "string" },
136
142
  regular_price: { type: "string" },
137
143
  sale_price: { type: "string" },
144
+ tax_class: { type: "string" },
145
+ tax_status: { type: "string" },
138
146
  on_sale: { type: "boolean" },
139
147
  stock_status: { type: "string" },
140
148
  stock_quantity: { type: ["number", "null"] },
@@ -230,7 +238,9 @@ function toVariationDocument(payload) {
230
238
  stock_quantity,
231
239
  manage_stock,
232
240
  status,
233
- purchasable
241
+ purchasable,
242
+ tax_class,
243
+ tax_status
234
244
  } = payload;
235
245
  return Object.fromEntries(Object.entries({
236
246
  id,
@@ -245,6 +255,8 @@ function toVariationDocument(payload) {
245
255
  manage_stock,
246
256
  status,
247
257
  purchasable,
258
+ tax_class,
259
+ tax_status,
248
260
  attributes: (payload.attributes ?? []).map(({ name, option }) => ({ name, option }))
249
261
  }).filter(([, value]) => value !== void 0));
250
262
  }
@@ -379,6 +391,17 @@ var wooProductTraits = {
379
391
  getId: (doc) => String(doc.id),
380
392
  getName: (doc) => doc.name ?? "",
381
393
  getSku: (doc) => doc.sku || void 0,
394
+ getTaxClass: (doc, variantId) => {
395
+ const variation = variantId === void 0 ? void 0 : doc.variation_docs?.find((v) => String(v.id) === variantId);
396
+ const taxClass = typeof variation?.tax_class === "string" && variation.tax_class !== "parent" ? variation.tax_class : doc.tax_class;
397
+ return taxClass === void 0 || taxClass === "" || taxClass === "standard" ? void 0 : taxClass;
398
+ },
399
+ getTaxStatus: (doc, variantId) => {
400
+ const variation = variantId === void 0 ? void 0 : doc.variation_docs?.find((v) => String(v.id) === variantId);
401
+ const taxStatus = typeof variation?.tax_status === "string" ? variation.tax_status : doc.tax_status;
402
+ if (taxStatus === "none" || taxStatus === "shipping") return "none";
403
+ return taxStatus === "taxable" ? "taxable" : void 0;
404
+ },
382
405
  getPrices: (doc, context) => {
383
406
  const currency = context?.currency ?? "XXX";
384
407
  const prices = [];
@@ -693,50 +716,211 @@ function createWooCustomers(options) {
693
716
  };
694
717
  }
695
718
 
719
+ // src/store-settings.ts
720
+ import { ConnectorUnauthorizedError as ConnectorUnauthorizedError2, StoreSettingsError, minorUnitDigits, woocommerceTax } from "@tallyui/core";
721
+ var WOO_ORDER_CREATE_VERSION = 3;
722
+ async function wooStoreSettings(context) {
723
+ try {
724
+ const init = { method: "GET", headers: context.headers, signal: context.signal };
725
+ const storesResponse = await fetch(`${context.baseUrl}/stores`, init);
726
+ if (storesResponse.status === 401 || storesResponse.status === 403) {
727
+ throw new ConnectorUnauthorizedError2(`WooCommerce stores: HTTP ${storesResponse.status}`, storesResponse.status);
728
+ }
729
+ if (!storesResponse.ok) throw new Error(`WooCommerce stores: HTTP ${storesResponse.status}`);
730
+ const stores = await storesResponse.json();
731
+ if (!Array.isArray(stores) || stores.length === 0) throw new Error("WooCommerce stores: expected a non-empty array");
732
+ const store = stores[0];
733
+ const currency = store.currency.toUpperCase();
734
+ const digits = minorUnitDigits(currency);
735
+ if (typeof store.price_num_decimals === "number" && store.price_num_decimals !== digits) {
736
+ console.warn(`WooCommerce price_num_decimals is ${store.price_num_decimals}; the till rounds ${currency} to ${digits} digits`);
737
+ }
738
+ const classesResponse = await fetch(`${context.baseUrl}/taxes/classes`, init);
739
+ if (classesResponse.status === 401 || classesResponse.status === 403) {
740
+ throw new ConnectorUnauthorizedError2(`WooCommerce tax classes: HTTP ${classesResponse.status}`, classesResponse.status);
741
+ }
742
+ if (!classesResponse.ok) throw new Error(`WooCommerce tax classes: HTTP ${classesResponse.status}`);
743
+ const classes = await classesResponse.json();
744
+ if (!Array.isArray(classes)) throw new Error("WooCommerce tax classes: expected an array");
745
+ const taxRates = {};
746
+ const taxRatesPpm = { default: 0 };
747
+ const settings = {
748
+ currency,
749
+ pricesIncludeTax: store.prices_include_tax === "yes",
750
+ taxRoundAtSubtotal: store.tax_round_at_subtotal === "yes",
751
+ ...typeof store.shipping_tax_class === "string" ? { shippingTaxClass: store.shipping_tax_class } : {},
752
+ taxClassSlugs: classes.map((row) => row.slug),
753
+ taxRates,
754
+ taxRatesPpm
755
+ };
756
+ if (store.calc_taxes !== "yes") return settings;
757
+ const rates = [];
758
+ for (let page = 1; page <= 100; page++) {
759
+ const response = await fetch(`${context.baseUrl}/taxes?per_page=100&page=${page}&orderby=order&order=asc`, init);
760
+ if (response.status === 401 || response.status === 403) {
761
+ throw new ConnectorUnauthorizedError2(`WooCommerce taxes: HTTP ${response.status}`, response.status);
762
+ }
763
+ if (!response.ok) throw new Error(`WooCommerce taxes: HTTP ${response.status}`);
764
+ const rows = await response.json();
765
+ if (!Array.isArray(rows)) throw new Error("WooCommerce taxes: expected an array");
766
+ rates.push(...rows.map((row) => ({
767
+ ...row,
768
+ postcodes: row.postcodes ?? [],
769
+ cities: row.cities ?? []
770
+ })));
771
+ if (rows.length < 100) break;
772
+ }
773
+ const taxAddress = store.tax_address;
774
+ let address = taxAddress && typeof taxAddress === "object" && typeof taxAddress.country === "string" && taxAddress.country.length > 0 ? taxAddress : { country: store.store_country, state: store.store_state, postcode: store.store_postcode, city: store.store_city };
775
+ if (!address.country) {
776
+ console.warn("WooCommerce has no tax or store country; matching tax rates with an empty address");
777
+ address = {};
778
+ }
779
+ const matched = woocommerceTax.filterTaxRates(rates, address.country, address.state, address.postcode, address.city);
780
+ for (const rate of matched) {
781
+ const slug = woocommerceTax.normalizeTaxClass(rate.class);
782
+ (taxRates[slug] ??= []).push({
783
+ id: rate.id,
784
+ code: [rate.country, rate.state, rate.name || "TAX", String(rate.priority)].filter(Boolean).join("-").toUpperCase(),
785
+ label: rate.name,
786
+ rate: rate.rate,
787
+ priority: rate.priority,
788
+ compound: rate.compound,
789
+ shipping: rate.shipping
790
+ });
791
+ const key = slug === "standard" ? "default" : slug;
792
+ taxRatesPpm[key] = (taxRatesPpm[key] ?? 0) + Math.round(Number(rate.rate) * 1e4);
793
+ }
794
+ return settings;
795
+ } catch (error) {
796
+ if (error instanceof ConnectorUnauthorizedError2) throw error;
797
+ throw new StoreSettingsError("failed", error instanceof Error ? error.message : String(error));
798
+ }
799
+ }
800
+ async function readWooCapabilities(context) {
801
+ try {
802
+ const response = await fetch(`${context.baseUrl}/stores`, {
803
+ method: "GET",
804
+ headers: context.headers,
805
+ signal: context.signal
806
+ });
807
+ if (response.status === 401 || response.status === 403) {
808
+ throw new ConnectorUnauthorizedError2(`WooCommerce stores: HTTP ${response.status}`, response.status);
809
+ }
810
+ if (!response.ok) return void 0;
811
+ const stores = await response.json();
812
+ if (!Array.isArray(stores) || !stores[0] || typeof stores[0] !== "object" || Array.isArray(stores[0])) return void 0;
813
+ let multiplePayments = false;
814
+ try {
815
+ const status = await fetch(`${context.baseUrl}/status`, {
816
+ method: "GET",
817
+ headers: context.headers,
818
+ signal: context.signal
819
+ });
820
+ if (status.status === 401 || status.status === 403) {
821
+ throw new ConnectorUnauthorizedError2(`WooCommerce status: HTTP ${status.status}`, status.status);
822
+ }
823
+ if (status.ok) {
824
+ const body = await status.json();
825
+ multiplePayments = Array.isArray(body?.capabilities) && body.capabilities.includes("order_payments_list");
826
+ }
827
+ } catch (error) {
828
+ if (error instanceof ConnectorUnauthorizedError2) throw error;
829
+ }
830
+ return {
831
+ orderCreate: WOO_ORDER_CREATE_VERSION,
832
+ taxRounding: { granularity: "woocommerce", roundAtSubtotal: stores[0].tax_round_at_subtotal === "yes" },
833
+ multiplePayments,
834
+ // WCPOS always 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.
835
+ lineTax: { none: true, classes: true }
836
+ };
837
+ } catch (error) {
838
+ if (error instanceof ConnectorUnauthorizedError2) throw error;
839
+ return void 0;
840
+ }
841
+ }
842
+
696
843
  // package.json
697
- var version = "3.2.0";
844
+ var version = "3.3.0";
698
845
 
699
846
  // src/commands/transport.ts
700
- import { minorUnitDigits, moneyFromDecimalString, moneyFromMajor as moneyFromMajor2 } from "@tallyui/core";
847
+ import { minorUnitDigits as minorUnitDigits2, moneyFromDecimalString, moneyFromMajor as moneyFromMajor2 } from "@tallyui/core";
701
848
  function wooMinorFromDecimal(text, currency) {
702
849
  const trimmed = text.trim();
703
850
  if (!/^\d+(\.\d+)?$/.test(trimmed)) return void 0;
704
- const digits = minorUnitDigits(currency);
851
+ const digits = minorUnitDigits2(currency);
705
852
  const [whole, fraction = ""] = trimmed.split(".");
706
853
  let end = fraction.length;
707
854
  while (end > digits && fraction[end - 1] === "0") end--;
708
855
  if (end > digits) return moneyFromMajor2(trimmed, currency)?.amount;
709
856
  return moneyFromDecimalString(end ? `${whole}.${fraction.slice(0, end)}` : whole, currency)?.amount;
710
857
  }
711
- function toWooOrderPayload(envelope) {
858
+ function toWooOrderPayload(envelope, local, options) {
712
859
  const p = envelope.payload;
713
- if (p.pricesIncludeTax || p.lines.some((line) => line.taxInclusive === true)) {
714
- return { error: { code: "unsupported_tax_mode", message: "Prices that include tax are not supported for WooCommerce yet." } };
860
+ if (envelope.version === 5 && (p.fees?.length || p.shipping?.length || p.lines.some((line) => line.custom))) {
861
+ return { error: { code: "invalid_payload", message: "Fees, shipping and custom lines are not supported by this connector yet." } };
715
862
  }
716
- if (p.payments.length !== 1) {
863
+ if (p.payments.length === 0 || p.payments.length > 1 && !options?.paymentsList) {
717
864
  return { error: { code: "invalid_payload", message: "WooCommerce orders take one payment." } };
718
865
  }
866
+ if (p.payments.length > 20) {
867
+ return { error: { code: "invalid_payload", message: "WooCommerce records at most 20 payments." } };
868
+ }
869
+ if (p.payments.length > 1 && p.payments.reduce((sum, payment) => sum + payment.amountMinor, 0) !== p.totalMinor) {
870
+ return { error: { code: "invalid_payload", message: "Payments do not add up to the order total." } };
871
+ }
719
872
  for (const line of p.lines) {
720
- if (!/^[1-9]\d*$/.test(line.variantId) || !Number.isSafeInteger(Number(line.variantId))) {
873
+ if (!/^[1-9]\d*$/.test(line.variantId ?? "") || !Number.isSafeInteger(Number(line.variantId))) {
721
874
  return { error: { code: "invalid_payload", message: `Line ${line.clientLineId} has no WooCommerce product id.` } };
722
875
  }
723
876
  }
724
- const digits = p.display?.exponent ?? minorUnitDigits(p.currency);
877
+ const digits = p.display?.exponent ?? minorUnitDigits2(p.currency);
725
878
  const major = (minor) => (minor / 10 ** digits).toFixed(digits);
726
- const cash = p.payments[0].method === "cash";
879
+ const line_items = [];
880
+ for (const line of p.lines) {
881
+ let subtotal = major(line.unitPriceMinor * line.quantity);
882
+ let total = major(line.unitPriceMinor * line.quantity - (line.discountMinor ?? 0));
883
+ if (line.taxInclusive ?? p.pricesIncludeTax) {
884
+ if ((line.discountMinor ?? 0) > 0) {
885
+ return { error: { code: "unsupported_tax_mode", message: "Discounted tax-inclusive lines are not supported for WooCommerce yet." } };
886
+ }
887
+ const netMicros = local?.lines.find((stored) => stored.id === line.clientLineId)?.netMicros;
888
+ if (netMicros === void 0) {
889
+ return { error: { code: "unsupported_tax_mode", message: "Tax-inclusive prices need the till's WooCommerce tax figures; update the till." } };
890
+ }
891
+ const net = BigInt(netMicros), divisor = 10n ** BigInt(digits);
892
+ if (net < 0n) {
893
+ return { error: { code: "invalid_payload", message: `Line ${line.clientLineId} has a negative net.` } };
894
+ }
895
+ if (net % divisor !== 0n) {
896
+ return { error: { code: "invalid_payload", message: `Line ${line.clientLineId} has a net finer than 6 decimals.` } };
897
+ }
898
+ const micros = net / divisor;
899
+ subtotal = total = `${micros / 1000000n}.${(micros % 1000000n).toString().padStart(6, "0")}`;
900
+ }
901
+ line_items.push({ product_id: Number(line.variantId), quantity: line.quantity, subtotal, total });
902
+ }
903
+ const primary = p.payments.reduce((largest, payment) => payment.amountMinor > largest.amountMinor ? payment : largest);
904
+ const cash = primary.method === "cash";
905
+ const meta_data = [{ key: "_woocommerce_pos_uuid", value: p.clientOrderId }];
906
+ if (p.payments.length > 1) {
907
+ meta_data.push({ key: "_woocommerce_pos_payments", value: JSON.stringify(p.payments.map((payment) => ({
908
+ method: payment.method === "cash" ? "pos_cash" : "pos_card",
909
+ title: payment.method === "cash" ? "Cash" : "Card",
910
+ amount: major(payment.amountMinor),
911
+ ...payment.reference !== void 0 ? { reference: payment.reference } : {},
912
+ ...payment.method === "cash" && payment.tenderedMinor !== void 0 ? { tendered: major(payment.tenderedMinor) } : {},
913
+ ...payment.method === "cash" && payment.changeMinor !== void 0 ? { change: major(payment.changeMinor) } : {}
914
+ }))) });
915
+ }
727
916
  return { payload: {
728
917
  status: "completed",
729
918
  set_paid: true,
730
919
  currency: p.currency,
731
920
  payment_method: cash ? "pos_cash" : "pos_card",
732
921
  payment_method_title: cash ? "Cash" : "Card",
733
- line_items: p.lines.map((line) => ({
734
- product_id: Number(line.variantId),
735
- quantity: line.quantity,
736
- subtotal: major(line.unitPriceMinor * line.quantity),
737
- total: major(line.unitPriceMinor * line.quantity - (line.discountMinor ?? 0))
738
- })),
739
- meta_data: [{ key: "_woocommerce_pos_uuid", value: p.clientOrderId }],
922
+ line_items,
923
+ meta_data,
740
924
  ...p.customer?.customerId && /^\d+$/.test(p.customer.customerId) ? { customer_id: Number(p.customer.customerId) } : {},
741
925
  ...p.customer?.email ? { billing: { email: p.customer.email } } : {}
742
926
  } };
@@ -746,10 +930,11 @@ function createWooCommandTransport(options) {
746
930
  let baseUrl = options.baseUrl;
747
931
  while (baseUrl.endsWith("/")) baseUrl = baseUrl.slice(0, -1);
748
932
  return {
749
- async send(batch) {
933
+ async send(batch, context) {
934
+ const paymentsList = typeof options.acceptsPaymentsList === "function" ? await options.acceptsPaymentsList() : options.acceptsPaymentsList ?? false;
750
935
  const results = [];
751
936
  for (const envelope of batch) {
752
- const mapped = toWooOrderPayload(envelope);
937
+ const mapped = toWooOrderPayload(envelope, context?.local?.orders?.get(envelope.id), { paymentsList });
753
938
  if ("error" in mapped) {
754
939
  results.push({ id: envelope.id, status: "rejected", error: mapped.error });
755
940
  continue;
@@ -827,11 +1012,11 @@ function createWooCommandTransport(options) {
827
1012
  }
828
1013
 
829
1014
  // src/index.ts
830
- import { ConnectorUnauthorizedError as ConnectorUnauthorizedError3 } from "@tallyui/core";
1015
+ import { ConnectorUnauthorizedError as ConnectorUnauthorizedError4 } from "@tallyui/core";
831
1016
  function wcposClientPart(part) {
832
1017
  return part.toLowerCase().replace(/[^a-z0-9._-]/g, "").slice(0, 32);
833
1018
  }
834
- var WooMissingTokenError = class extends ConnectorUnauthorizedError2 {
1019
+ var WooMissingTokenError = class extends ConnectorUnauthorizedError3 {
835
1020
  constructor() {
836
1021
  super("WooCommerce credentials have no WCPOS access token: sign in again", 401);
837
1022
  this.name = "WooMissingTokenError";
@@ -862,6 +1047,8 @@ var wooConnectorParts = {
862
1047
  description: "Connect to WooCommerce stores via the REST API",
863
1048
  icon: void 0,
864
1049
  // TODO: WooCommerce logo
1050
+ storeSettings: wooStoreSettings,
1051
+ capabilities: readWooCapabilities,
865
1052
  auth: {
866
1053
  type: "WCPOS token",
867
1054
  fields: [
@@ -905,7 +1092,8 @@ var wooConnectorParts = {
905
1092
  };
906
1093
  var woocommerceConnector = createWooCommerceConnector();
907
1094
  export {
908
- ConnectorUnauthorizedError3 as ConnectorUnauthorizedError,
1095
+ ConnectorUnauthorizedError4 as ConnectorUnauthorizedError,
1096
+ WOO_ORDER_CREATE_VERSION,
909
1097
  WooDateFilterError,
910
1098
  WooMissingTokenError,
911
1099
  WooMissingUuidError,
@@ -914,6 +1102,7 @@ export {
914
1102
  createWooCommandTransport,
915
1103
  createWooCommerceConnector,
916
1104
  createWooCustomers,
1105
+ readWooCapabilities,
917
1106
  toWooCustomer,
918
1107
  toWooOrderPayload,
919
1108
  wcposClientPart,
@@ -923,5 +1112,6 @@ export {
923
1112
  wooProductSync,
924
1113
  wooProductTraits,
925
1114
  wooReconcileFingerprint,
1115
+ wooStoreSettings,
926
1116
  woocommerceConnector
927
1117
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/connector-woocommerce",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "type": "module",
5
5
  "description": "WooCommerce connector for Tally UI",
6
6
  "main": "./dist/index.js",
@@ -26,10 +26,10 @@
26
26
  "directory": "connectors/woocommerce"
27
27
  },
28
28
  "peerDependencies": {
29
- "@tallyui/core": "^3.2.0"
29
+ "@tallyui/core": "^3.3.0"
30
30
  },
31
31
  "devDependencies": {
32
- "@tallyui/database": "3.2.0",
32
+ "@tallyui/database": "3.3.0",
33
33
  "rxdb": "17.5.0"
34
34
  },
35
35
  "scripts": {
@@ -12,6 +12,9 @@ export interface WooCommandTransportOptions {
12
12
  getHeaders: () => Record<string, string> | Promise<Record<string, string>>;
13
13
  fetch?: typeof fetch;
14
14
  timeoutMs?: number;
15
+ /** True when the store's `/status` lists `order_payments_list` (`ServerCapabilities.multiplePayments`): an order may
16
+ * then carry several payments, recorded in `_woocommerce_pos_payments`. */
17
+ acceptsPaymentsList?: boolean | (() => boolean | Promise<boolean>);
15
18
  }
16
19
 
17
20
  export function wooMinorFromDecimal(text: string, currency: string): number | undefined {
@@ -25,45 +28,88 @@ export function wooMinorFromDecimal(text: string, currency: string): number | un
25
28
  return moneyFromDecimalString(end ? `${whole}.${fraction.slice(0, end)}` : whole, currency)?.amount;
26
29
  }
27
30
 
28
- export function toWooOrderPayload(envelope: OrderCreateEnvelope): { payload: Record<string, unknown> } | { error: { code: string; message: string } } {
31
+ export interface WooLocalOrder { lines: ReadonlyArray<{ id: string; netMicros?: string }> }
32
+
33
+ export function toWooOrderPayload(envelope: OrderCreateEnvelope, local?: WooLocalOrder, options?: { paymentsList?: boolean }): { payload: Record<string, unknown> } | { error: { code: string; message: string } } {
29
34
  const p = envelope.payload;
30
- if (p.pricesIncludeTax || p.lines.some((line) => line.taxInclusive === true)) {
31
- return { error: { code: 'unsupported_tax_mode', message: 'Prices that include tax are not supported for WooCommerce yet.' } };
35
+ if (envelope.version === 5 && (p.fees?.length || p.shipping?.length || p.lines.some((line) => line.custom))) {
36
+ return { error: { code: 'invalid_payload', message: 'Fees, shipping and custom lines are not supported by this connector yet.' } };
32
37
  }
33
- if (p.payments.length !== 1) {
38
+ if (p.payments.length === 0 || (p.payments.length > 1 && !options?.paymentsList)) {
34
39
  return { error: { code: 'invalid_payload', message: 'WooCommerce orders take one payment.' } };
35
40
  }
41
+ if (p.payments.length > 20) {
42
+ return { error: { code: 'invalid_payload', message: 'WooCommerce records at most 20 payments.' } };
43
+ }
44
+ if (p.payments.length > 1 && p.payments.reduce((sum, payment) => sum + payment.amountMinor, 0) !== p.totalMinor) {
45
+ return { error: { code: 'invalid_payload', message: 'Payments do not add up to the order total.' } };
46
+ }
36
47
  for (const line of p.lines) {
37
- if (!/^[1-9]\d*$/.test(line.variantId) || !Number.isSafeInteger(Number(line.variantId))) {
48
+ if (!/^[1-9]\d*$/.test(line.variantId ?? '') || !Number.isSafeInteger(Number(line.variantId))) {
38
49
  return { error: { code: 'invalid_payload', message: `Line ${line.clientLineId} has no WooCommerce product id.` } };
39
50
  }
40
51
  }
41
52
  const digits = p.display?.exponent ?? minorUnitDigits(p.currency);
42
53
  const major = (minor: number) => (minor / 10 ** digits).toFixed(digits);
43
- const cash = p.payments[0].method === 'cash';
54
+ const line_items = [];
55
+ for (const line of p.lines) {
56
+ let subtotal = major(line.unitPriceMinor * line.quantity);
57
+ let total = major(line.unitPriceMinor * line.quantity - (line.discountMinor ?? 0));
58
+ if (line.taxInclusive ?? p.pricesIncludeTax) {
59
+ if ((line.discountMinor ?? 0) > 0) {
60
+ return { error: { code: 'unsupported_tax_mode', message: 'Discounted tax-inclusive lines are not supported for WooCommerce yet.' } };
61
+ }
62
+ const netMicros = local?.lines.find((stored) => stored.id === line.clientLineId)?.netMicros;
63
+ if (netMicros === undefined) {
64
+ return { error: { code: 'unsupported_tax_mode', message: "Tax-inclusive prices need the till's WooCommerce tax figures; update the till." } };
65
+ }
66
+ const net = BigInt(netMicros), divisor = 10n ** BigInt(digits);
67
+ if (net < 0n) {
68
+ return { error: { code: 'invalid_payload', message: `Line ${line.clientLineId} has a negative net.` } };
69
+ }
70
+ if (net % divisor !== 0n) {
71
+ return { error: { code: 'invalid_payload', message: `Line ${line.clientLineId} has a net finer than 6 decimals.` } };
72
+ }
73
+ const micros = net / divisor;
74
+ subtotal = total = `${micros / 1000000n}.${(micros % 1000000n).toString().padStart(6, '0')}`;
75
+ }
76
+ line_items.push({ product_id: Number(line.variantId), quantity: line.quantity, subtotal, total });
77
+ }
78
+ const primary = p.payments.reduce((largest, payment) => payment.amountMinor > largest.amountMinor ? payment : largest);
79
+ const cash = primary.method === 'cash';
80
+ const meta_data = [{ key: '_woocommerce_pos_uuid', value: p.clientOrderId }];
81
+ if (p.payments.length > 1) {
82
+ meta_data.push({ key: '_woocommerce_pos_payments', value: JSON.stringify(p.payments.map((payment) => ({
83
+ method: payment.method === 'cash' ? 'pos_cash' : 'pos_card',
84
+ title: payment.method === 'cash' ? 'Cash' : 'Card', amount: major(payment.amountMinor),
85
+ ...(payment.reference !== undefined ? { reference: payment.reference } : {}),
86
+ ...(payment.method === 'cash' && payment.tenderedMinor !== undefined ? { tendered: major(payment.tenderedMinor) } : {}),
87
+ ...(payment.method === 'cash' && payment.changeMinor !== undefined ? { change: major(payment.changeMinor) } : {}),
88
+ }))) });
89
+ }
44
90
  return { payload: {
45
91
  status: 'completed', set_paid: true, currency: p.currency,
46
92
  payment_method: cash ? 'pos_cash' : 'pos_card', payment_method_title: cash ? 'Cash' : 'Card',
47
- line_items: p.lines.map((line) => ({
48
- product_id: Number(line.variantId), quantity: line.quantity,
49
- subtotal: major(line.unitPriceMinor * line.quantity),
50
- total: major(line.unitPriceMinor * line.quantity - (line.discountMinor ?? 0)),
51
- })),
52
- meta_data: [{ key: '_woocommerce_pos_uuid', value: p.clientOrderId }],
93
+ line_items,
94
+ meta_data,
53
95
  ...(p.customer?.customerId && /^\d+$/.test(p.customer.customerId) ? { customer_id: Number(p.customer.customerId) } : {}),
54
96
  ...(p.customer?.email ? { billing: { email: p.customer.email } } : {}),
55
97
  } };
56
98
  }
57
99
 
58
- export function createWooCommandTransport(options: WooCommandTransportOptions): { send(batch: OrderCreateEnvelope[]): Promise<WooTransportOutcome> } {
100
+ export function createWooCommandTransport(options: WooCommandTransportOptions): {
101
+ send(batch: OrderCreateEnvelope[], context?: { local?: { orders?: ReadonlyMap<string, WooLocalOrder> } }): Promise<WooTransportOutcome>;
102
+ } {
59
103
  const fetch = options.fetch ?? globalThis.fetch;
60
104
  let baseUrl = options.baseUrl;
61
105
  while (baseUrl.endsWith('/')) baseUrl = baseUrl.slice(0, -1);
62
106
  return {
63
- async send(batch) {
107
+ async send(batch, context) {
108
+ const paymentsList = typeof options.acceptsPaymentsList === 'function'
109
+ ? await options.acceptsPaymentsList() : options.acceptsPaymentsList ?? false;
64
110
  const results: CommandResult[] = [];
65
111
  for (const envelope of batch) {
66
- const mapped = toWooOrderPayload(envelope);
112
+ const mapped = toWooOrderPayload(envelope, context?.local?.orders?.get(envelope.id), { paymentsList });
67
113
  if ('error' in mapped) {
68
114
  results.push({ id: envelope.id, status: 'rejected', error: mapped.error });
69
115
  continue;
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import { wooProductReplication } from './replication/products';
7
7
  import { wooCatalogueReconcile } from './reconcile/catalogue';
8
8
  import { createWooReconcileFeed, MAX_IDS_PER_REQUEST } from './reconcile/feed';
9
9
  import { createWooCustomers } from './customers';
10
+ import { wooStoreSettings, readWooCapabilities } from './store-settings';
10
11
  import { version } from '../package.json';
11
12
 
12
13
  /** One part of X-WCPOS-Client as WCPOS keeps it: lowercase [a-z0-9._-], at most 32 characters. */
@@ -66,6 +67,8 @@ const wooConnectorParts = {
66
67
  name: 'WooCommerce',
67
68
  description: 'Connect to WooCommerce stores via the REST API',
68
69
  icon: undefined, // TODO: WooCommerce logo
70
+ storeSettings: wooStoreSettings,
71
+ capabilities: readWooCapabilities,
69
72
 
70
73
  auth: {
71
74
  type: 'WCPOS token',
@@ -119,6 +122,7 @@ const wooConnectorParts = {
119
122
  export const woocommerceConnector: TallyConnector = createWooCommerceConnector();
120
123
 
121
124
  // Re-export pieces for advanced usage
125
+ export { wooStoreSettings, readWooCapabilities, WOO_ORDER_CREATE_VERSION } from './store-settings';
122
126
  export { createWooCustomers, toWooCustomer } from './customers';
123
127
  export { createWooCommandTransport, toWooOrderPayload, type WooCommandTransportOptions } from './commands/transport';
124
128
  export { ConnectorUnauthorizedError } from '@tallyui/core';
@@ -85,10 +85,11 @@ export function wooProductBarcode(product: any): string | undefined {
85
85
 
86
86
  export function toVariationDocument(payload: any) {
87
87
  const { id, sku, price, regular_price, sale_price, on_sale, stock_status,
88
- stock_quantity, manage_stock, status, purchasable } = payload;
88
+ stock_quantity, manage_stock, status, purchasable, tax_class, tax_status } = payload;
89
89
  return Object.fromEntries(Object.entries({
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
+ tax_class, tax_status,
92
93
  attributes: (payload.attributes ?? []).map(({ name, option }: any) => ({ name, option })),
93
94
  }).filter(([, value]) => value !== undefined));
94
95
  }
@@ -7,7 +7,7 @@ import type { RxJsonSchema } from 'rxdb';
7
7
  */
8
8
  export const wooProductSchema: RxJsonSchema<any> = {
9
9
  title: 'WooCommerce Product',
10
- version: 1,
10
+ version: 2,
11
11
  type: 'object',
12
12
  primaryKey: 'uuid',
13
13
  properties: {
@@ -53,6 +53,12 @@ export const wooProductSchema: RxJsonSchema<any> = {
53
53
  sale_price: {
54
54
  type: 'string',
55
55
  },
56
+ tax_class: {
57
+ type: 'string',
58
+ },
59
+ tax_status: {
60
+ type: 'string',
61
+ },
56
62
  on_sale: {
57
63
  type: 'boolean',
58
64
  },
@@ -134,6 +140,7 @@ export const wooProductSchema: RxJsonSchema<any> = {
134
140
  variation_docs: { type: 'array', items: { type: 'object', properties: {
135
141
  id: { type: 'integer' }, sku: { type: 'string' }, barcode: { type: 'string' },
136
142
  price: { type: 'string' }, regular_price: { type: 'string' }, sale_price: { type: 'string' },
143
+ tax_class: { type: 'string' }, tax_status: { type: 'string' },
137
144
  on_sale: { type: 'boolean' }, stock_status: { type: 'string' }, stock_quantity: { type: ['number', 'null'] },
138
145
  manage_stock: { type: 'boolean' }, status: { type: 'string' }, purchasable: { type: 'boolean' },
139
146
  attributes: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, option: { type: 'string' } } } },
@@ -0,0 +1,128 @@
1
+ import { ConnectorUnauthorizedError, StoreSettingsError, minorUnitDigits, woocommerceTax } from '@tallyui/core';
2
+ import type { ServerCapabilities, StoreSettings, SyncContext } from '@tallyui/core';
3
+
4
+ /** The highest order.create version the WooCommerce transport maps: discounts and the customer (v3). It rises when the transport maps split payments (v4) and fees, shipping and custom lines (v5). */
5
+ export const WOO_ORDER_CREATE_VERSION = 3;
6
+
7
+ export async function wooStoreSettings(context: SyncContext): Promise<StoreSettings> {
8
+ try {
9
+ const init = { method: 'GET', headers: context.headers, signal: context.signal };
10
+ const storesResponse = await fetch(`${context.baseUrl}/stores`, init);
11
+ if (storesResponse.status === 401 || storesResponse.status === 403) {
12
+ throw new ConnectorUnauthorizedError(`WooCommerce stores: HTTP ${storesResponse.status}`, storesResponse.status);
13
+ }
14
+ if (!storesResponse.ok) throw new Error(`WooCommerce stores: HTTP ${storesResponse.status}`);
15
+ const stores = await storesResponse.json();
16
+ if (!Array.isArray(stores) || stores.length === 0) throw new Error('WooCommerce stores: expected a non-empty array');
17
+ const store = stores[0];
18
+ const currency = store.currency.toUpperCase();
19
+ const digits = minorUnitDigits(currency);
20
+ if (typeof store.price_num_decimals === 'number' && store.price_num_decimals !== digits) {
21
+ console.warn(`WooCommerce price_num_decimals is ${store.price_num_decimals}; the till rounds ${currency} to ${digits} digits`);
22
+ }
23
+
24
+ const classesResponse = await fetch(`${context.baseUrl}/taxes/classes`, init);
25
+ if (classesResponse.status === 401 || classesResponse.status === 403) {
26
+ throw new ConnectorUnauthorizedError(`WooCommerce tax classes: HTTP ${classesResponse.status}`, classesResponse.status);
27
+ }
28
+ if (!classesResponse.ok) throw new Error(`WooCommerce tax classes: HTTP ${classesResponse.status}`);
29
+ const classes = await classesResponse.json();
30
+ if (!Array.isArray(classes)) throw new Error('WooCommerce tax classes: expected an array');
31
+
32
+ const taxRates: NonNullable<StoreSettings['taxRates']> = {};
33
+ /** Simple-rate view for strategies other than `woocommerce`; the WooCommerce strategy reads `taxRates`. */
34
+ const taxRatesPpm: StoreSettings['taxRatesPpm'] = { default: 0 };
35
+ const settings: StoreSettings = {
36
+ currency,
37
+ pricesIncludeTax: store.prices_include_tax === 'yes',
38
+ taxRoundAtSubtotal: store.tax_round_at_subtotal === 'yes',
39
+ ...(typeof store.shipping_tax_class === 'string' ? { shippingTaxClass: store.shipping_tax_class } : {}),
40
+ taxClassSlugs: classes.map((row) => row.slug),
41
+ taxRates,
42
+ taxRatesPpm,
43
+ };
44
+ if (store.calc_taxes !== 'yes') return settings;
45
+
46
+ const rates: woocommerceTax.WooTaxRate[] = [];
47
+ for (let page = 1; page <= 100; page++) {
48
+ const response = await fetch(`${context.baseUrl}/taxes?per_page=100&page=${page}&orderby=order&order=asc`, init);
49
+ if (response.status === 401 || response.status === 403) {
50
+ throw new ConnectorUnauthorizedError(`WooCommerce taxes: HTTP ${response.status}`, response.status);
51
+ }
52
+ if (!response.ok) throw new Error(`WooCommerce taxes: HTTP ${response.status}`);
53
+ const rows = await response.json();
54
+ if (!Array.isArray(rows)) throw new Error('WooCommerce taxes: expected an array');
55
+ rates.push(...rows.map((row: woocommerceTax.WooTaxRate) => ({
56
+ ...row, postcodes: row.postcodes ?? [], cities: row.cities ?? [],
57
+ })));
58
+ if (rows.length < 100) break;
59
+ }
60
+
61
+ const taxAddress = store.tax_address;
62
+ let address = taxAddress && typeof taxAddress === 'object' && typeof taxAddress.country === 'string' && taxAddress.country.length > 0
63
+ ? taxAddress
64
+ : { country: store.store_country, state: store.store_state, postcode: store.store_postcode, city: store.store_city };
65
+ if (!address.country) {
66
+ console.warn('WooCommerce has no tax or store country; matching tax rates with an empty address');
67
+ address = {};
68
+ }
69
+ const matched = woocommerceTax.filterTaxRates(rates, address.country, address.state, address.postcode, address.city);
70
+ for (const rate of matched) {
71
+ const slug = woocommerceTax.normalizeTaxClass(rate.class);
72
+ (taxRates[slug] ??= []).push({
73
+ id: rate.id,
74
+ code: [rate.country, rate.state, rate.name || 'TAX', String(rate.priority)].filter(Boolean).join('-').toUpperCase(),
75
+ label: rate.name,
76
+ rate: rate.rate,
77
+ priority: rate.priority,
78
+ compound: rate.compound,
79
+ shipping: rate.shipping,
80
+ });
81
+ const key = slug === 'standard' ? 'default' : slug;
82
+ taxRatesPpm[key] = (taxRatesPpm[key] ?? 0) + Math.round(Number(rate.rate) * 10000);
83
+ }
84
+ return settings;
85
+ } catch (error) {
86
+ if (error instanceof ConnectorUnauthorizedError) throw error;
87
+ throw new StoreSettingsError('failed', error instanceof Error ? error.message : String(error));
88
+ }
89
+ }
90
+
91
+ export async function readWooCapabilities(context: SyncContext): Promise<ServerCapabilities | undefined> {
92
+ try {
93
+ const response = await fetch(`${context.baseUrl}/stores`, {
94
+ method: 'GET', headers: context.headers, signal: context.signal,
95
+ });
96
+ if (response.status === 401 || response.status === 403) {
97
+ throw new ConnectorUnauthorizedError(`WooCommerce stores: HTTP ${response.status}`, response.status);
98
+ }
99
+ if (!response.ok) return undefined;
100
+ const stores = await response.json();
101
+ if (!Array.isArray(stores) || !stores[0] || typeof stores[0] !== 'object' || Array.isArray(stores[0])) return undefined;
102
+ let multiplePayments = false;
103
+ try {
104
+ const status = await fetch(`${context.baseUrl}/status`, {
105
+ method: 'GET', headers: context.headers, signal: context.signal,
106
+ });
107
+ if (status.status === 401 || status.status === 403) {
108
+ throw new ConnectorUnauthorizedError(`WooCommerce status: HTTP ${status.status}`, status.status);
109
+ }
110
+ if (status.ok) {
111
+ const body = await status.json();
112
+ multiplePayments = Array.isArray(body?.capabilities) && body.capabilities.includes('order_payments_list');
113
+ }
114
+ } catch (error) {
115
+ if (error instanceof ConnectorUnauthorizedError) throw error;
116
+ }
117
+ return {
118
+ orderCreate: WOO_ORDER_CREATE_VERSION,
119
+ taxRounding: { granularity: 'woocommerce', roundAtSubtotal: stores[0].tax_round_at_subtotal === 'yes' },
120
+ multiplePayments,
121
+ // WCPOS always 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.
122
+ lineTax: { none: true, classes: true },
123
+ };
124
+ } catch (error) {
125
+ if (error instanceof ConnectorUnauthorizedError) throw error;
126
+ return undefined;
127
+ }
128
+ }
@@ -22,6 +22,22 @@ export const wooProductTraits: ProductTraits = {
22
22
 
23
23
  getSku: (doc) => doc.sku || undefined,
24
24
 
25
+ getTaxClass: (doc, variantId) => {
26
+ const variation = variantId === undefined ? undefined
27
+ : doc.variation_docs?.find((v: any) => String(v.id) === variantId);
28
+ const taxClass = typeof variation?.tax_class === 'string' && variation.tax_class !== 'parent'
29
+ ? variation.tax_class : doc.tax_class;
30
+ return taxClass === undefined || taxClass === '' || taxClass === 'standard' ? undefined : taxClass;
31
+ },
32
+
33
+ getTaxStatus: (doc, variantId) => {
34
+ const variation = variantId === undefined ? undefined
35
+ : doc.variation_docs?.find((v: any) => String(v.id) === variantId);
36
+ const taxStatus = typeof variation?.tax_status === 'string' ? variation.tax_status : doc.tax_status;
37
+ if (taxStatus === 'none' || taxStatus === 'shipping') return 'none';
38
+ return taxStatus === 'taxable' ? 'taxable' : undefined;
39
+ },
40
+
25
41
  getPrices: (doc, context) => {
26
42
  // Woo prices are decimal strings with no currency; the store supplies it.
27
43
  const currency = context?.currency ?? 'XXX';