@tallyui/connector-woocommerce 3.2.1 → 3.4.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: v4's net discounts, and v5's fees, shipping and custom lines (WCPOS push/orders; orders #146, #161). */
6
+ declare const WOO_ORDER_CREATE_VERSION = 5;
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,27 @@ 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
+ }>;
65
+ fees?: ReadonlyArray<{
66
+ id: string;
67
+ netMicros?: string;
68
+ }>;
69
+ shipping?: ReadonlyArray<{
70
+ id: string;
71
+ netMicros?: string;
72
+ }>;
51
73
  }
52
- declare function toWooOrderPayload(envelope: OrderCreateEnvelope): {
74
+ declare function toWooOrderPayload(envelope: OrderCreateEnvelope, local?: WooLocalOrder, options?: {
75
+ paymentsList?: boolean;
76
+ }): {
53
77
  payload: Record<string, unknown>;
54
78
  } | {
55
79
  error: {
@@ -58,7 +82,11 @@ declare function toWooOrderPayload(envelope: OrderCreateEnvelope): {
58
82
  };
59
83
  };
60
84
  declare function createWooCommandTransport(options: WooCommandTransportOptions): {
61
- send(batch: OrderCreateEnvelope[]): Promise<WooTransportOutcome>;
85
+ send(batch: OrderCreateEnvelope[], context?: {
86
+ local?: {
87
+ orders?: ReadonlyMap<string, WooLocalOrder>;
88
+ };
89
+ }): Promise<WooTransportOutcome>;
62
90
  };
63
91
 
64
92
  /**
@@ -198,4 +226,4 @@ declare function createWooCommerceConnector(): TallyConnector;
198
226
  */
199
227
  declare const woocommerceConnector: TallyConnector;
200
228
 
201
- export { type WooCommandTransportOptions, WooDateFilterError, WooMissingTokenError, WooMissingUuidError, WooTillUpdateRequiredError, WooTokenRefusedError, createWooCommandTransport, createWooCommerceConnector, createWooCustomers, toWooCustomer, toWooOrderPayload, wcposClientPart, wooCatalogueReconcile, wooProductReplication, wooProductSchema, wooProductSync, wooProductTraits, wooReconcileFingerprint, woocommerceConnector };
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 };
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,236 @@ 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 = 5;
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.1";
844
+ var version = "3.4.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." } };
715
- }
716
- if (p.payments.length !== 1) {
860
+ if (p.payments.length === 0 || p.payments.length > 1 && !options?.paymentsList) {
717
861
  return { error: { code: "invalid_payload", message: "WooCommerce orders take one payment." } };
718
862
  }
863
+ if (p.payments.length > 20) {
864
+ return { error: { code: "invalid_payload", message: "WooCommerce records at most 20 payments." } };
865
+ }
866
+ if (p.payments.length > 1 && p.payments.reduce((sum, payment) => sum + payment.amountMinor, 0) !== p.totalMinor) {
867
+ return { error: { code: "invalid_payload", message: "Payments do not add up to the order total." } };
868
+ }
719
869
  for (const line of p.lines) {
720
- if (!/^[1-9]\d*$/.test(line.variantId) || !Number.isSafeInteger(Number(line.variantId))) {
870
+ if (!line.custom && (!/^[1-9]\d*$/.test(line.variantId ?? "") || !Number.isSafeInteger(Number(line.variantId)))) {
721
871
  return { error: { code: "invalid_payload", message: `Line ${line.clientLineId} has no WooCommerce product id.` } };
722
872
  }
723
873
  }
724
- const digits = p.display?.exponent ?? minorUnitDigits(p.currency);
874
+ const digits = p.display?.exponent ?? minorUnitDigits2(p.currency);
725
875
  const major = (minor) => (minor / 10 ** digits).toFixed(digits);
726
- const cash = p.payments[0].method === "cash";
876
+ const inclusiveNet = (netMicros, id) => {
877
+ if (netMicros === void 0) {
878
+ return { error: { code: "unsupported_tax_mode", message: "Tax-inclusive prices need the till's WooCommerce tax figures; update the till." } };
879
+ }
880
+ const net = BigInt(netMicros), divisor = 10n ** BigInt(digits);
881
+ if (net < 0n) {
882
+ return { error: { code: "invalid_payload", message: `Line ${id} has a negative net.` } };
883
+ }
884
+ if (net % divisor !== 0n) {
885
+ return { error: { code: "invalid_payload", message: `Line ${id} has a net finer than 6 decimals.` } };
886
+ }
887
+ const micros = net / divisor;
888
+ return `${micros / 1000000n}.${(micros % 1000000n).toString().padStart(6, "0")}`;
889
+ };
890
+ const line_items = [];
891
+ for (const line of p.lines) {
892
+ let total = major(line.unitPriceMinor * line.quantity - (line.discountMinor ?? 0));
893
+ let subtotal = total;
894
+ if (line.taxInclusive ?? p.pricesIncludeTax) {
895
+ const net = inclusiveNet(local?.lines.find((stored) => stored.id === line.clientLineId)?.netMicros, line.clientLineId);
896
+ if (typeof net !== "string") return net;
897
+ subtotal = total = net;
898
+ }
899
+ const custom = line.custom;
900
+ const price = major(line.unitPriceMinor);
901
+ line_items.push(custom ? {
902
+ product_id: 0,
903
+ name: custom.name,
904
+ ...custom.sku ? { sku: custom.sku } : {},
905
+ quantity: line.quantity,
906
+ subtotal,
907
+ total,
908
+ tax_class: custom.taxClass ?? "",
909
+ meta_data: [{
910
+ key: "_woocommerce_pos_data",
911
+ value: JSON.stringify({ price, regular_price: price, tax_status: custom.taxStatus })
912
+ }]
913
+ } : { product_id: Number(line.variantId), quantity: line.quantity, subtotal, total });
914
+ }
915
+ const fee_lines = [], shipping_lines = [];
916
+ for (const fee of p.fees ?? []) {
917
+ const total = p.pricesIncludeTax ? inclusiveNet(local?.fees?.find((stored) => stored.id === fee.clientFeeId)?.netMicros, fee.clientFeeId) : major(fee.amountMinor);
918
+ if (typeof total !== "string") return total;
919
+ fee_lines.push({ name: fee.name, total, tax_status: fee.taxStatus, tax_class: fee.taxClass ?? "" });
920
+ }
921
+ for (const shipping of p.shipping ?? []) {
922
+ const total = p.pricesIncludeTax ? inclusiveNet(local?.shipping?.find((stored) => stored.id === shipping.clientShippingId)?.netMicros, shipping.clientShippingId) : major(shipping.amountMinor);
923
+ if (typeof total !== "string") return total;
924
+ shipping_lines.push({ method_id: shipping.methodId ?? "pos", method_title: shipping.name, total });
925
+ }
926
+ const primary = p.payments.reduce((largest, payment) => payment.amountMinor > largest.amountMinor ? payment : largest);
927
+ const cash = primary.method === "cash";
928
+ const meta_data = [{ key: "_woocommerce_pos_uuid", value: p.clientOrderId }];
929
+ if (p.payments.length > 1) {
930
+ meta_data.push({ key: "_woocommerce_pos_payments", value: JSON.stringify(p.payments.map((payment) => ({
931
+ method: payment.method === "cash" ? "pos_cash" : "pos_card",
932
+ title: payment.method === "cash" ? "Cash" : "Card",
933
+ amount: major(payment.amountMinor),
934
+ ...payment.reference !== void 0 ? { reference: payment.reference } : {},
935
+ ...payment.method === "cash" && payment.tenderedMinor !== void 0 ? { tendered: major(payment.tenderedMinor) } : {},
936
+ ...payment.method === "cash" && payment.changeMinor !== void 0 ? { change: major(payment.changeMinor) } : {}
937
+ }))) });
938
+ }
727
939
  return { payload: {
728
940
  status: "completed",
729
941
  set_paid: true,
730
942
  currency: p.currency,
731
943
  payment_method: cash ? "pos_cash" : "pos_card",
732
944
  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 }],
945
+ line_items,
946
+ ...fee_lines.length ? { fee_lines } : {},
947
+ ...shipping_lines.length ? { shipping_lines } : {},
948
+ meta_data,
740
949
  ...p.customer?.customerId && /^\d+$/.test(p.customer.customerId) ? { customer_id: Number(p.customer.customerId) } : {},
741
950
  ...p.customer?.email ? { billing: { email: p.customer.email } } : {}
742
951
  } };
@@ -746,10 +955,11 @@ function createWooCommandTransport(options) {
746
955
  let baseUrl = options.baseUrl;
747
956
  while (baseUrl.endsWith("/")) baseUrl = baseUrl.slice(0, -1);
748
957
  return {
749
- async send(batch) {
958
+ async send(batch, context) {
959
+ const paymentsList = typeof options.acceptsPaymentsList === "function" ? await options.acceptsPaymentsList() : options.acceptsPaymentsList ?? false;
750
960
  const results = [];
751
961
  for (const envelope of batch) {
752
- const mapped = toWooOrderPayload(envelope);
962
+ const mapped = toWooOrderPayload(envelope, context?.local?.orders?.get(envelope.id), { paymentsList });
753
963
  if ("error" in mapped) {
754
964
  results.push({ id: envelope.id, status: "rejected", error: mapped.error });
755
965
  continue;
@@ -827,11 +1037,11 @@ function createWooCommandTransport(options) {
827
1037
  }
828
1038
 
829
1039
  // src/index.ts
830
- import { ConnectorUnauthorizedError as ConnectorUnauthorizedError3 } from "@tallyui/core";
1040
+ import { ConnectorUnauthorizedError as ConnectorUnauthorizedError4 } from "@tallyui/core";
831
1041
  function wcposClientPart(part) {
832
1042
  return part.toLowerCase().replace(/[^a-z0-9._-]/g, "").slice(0, 32);
833
1043
  }
834
- var WooMissingTokenError = class extends ConnectorUnauthorizedError2 {
1044
+ var WooMissingTokenError = class extends ConnectorUnauthorizedError3 {
835
1045
  constructor() {
836
1046
  super("WooCommerce credentials have no WCPOS access token: sign in again", 401);
837
1047
  this.name = "WooMissingTokenError";
@@ -862,6 +1072,8 @@ var wooConnectorParts = {
862
1072
  description: "Connect to WooCommerce stores via the REST API",
863
1073
  icon: void 0,
864
1074
  // TODO: WooCommerce logo
1075
+ storeSettings: wooStoreSettings,
1076
+ capabilities: readWooCapabilities,
865
1077
  auth: {
866
1078
  type: "WCPOS token",
867
1079
  fields: [
@@ -905,7 +1117,8 @@ var wooConnectorParts = {
905
1117
  };
906
1118
  var woocommerceConnector = createWooCommerceConnector();
907
1119
  export {
908
- ConnectorUnauthorizedError3 as ConnectorUnauthorizedError,
1120
+ ConnectorUnauthorizedError4 as ConnectorUnauthorizedError,
1121
+ WOO_ORDER_CREATE_VERSION,
909
1122
  WooDateFilterError,
910
1123
  WooMissingTokenError,
911
1124
  WooMissingUuidError,
@@ -914,6 +1127,7 @@ export {
914
1127
  createWooCommandTransport,
915
1128
  createWooCommerceConnector,
916
1129
  createWooCustomers,
1130
+ readWooCapabilities,
917
1131
  toWooCustomer,
918
1132
  toWooOrderPayload,
919
1133
  wcposClientPart,
@@ -923,5 +1137,6 @@ export {
923
1137
  wooProductSync,
924
1138
  wooProductTraits,
925
1139
  wooReconcileFingerprint,
1140
+ wooStoreSettings,
926
1141
  woocommerceConnector
927
1142
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tallyui/connector-woocommerce",
3
- "version": "3.2.1",
3
+ "version": "3.4.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.1"
29
+ "@tallyui/core": "^3.4.0"
30
30
  },
31
31
  "devDependencies": {
32
- "@tallyui/database": "3.2.1",
32
+ "@tallyui/database": "3.4.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,111 @@ 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 {
32
+ lines: ReadonlyArray<{ id: string; netMicros?: string }>;
33
+ fees?: ReadonlyArray<{ id: string; netMicros?: string }>;
34
+ shipping?: ReadonlyArray<{ id: string; netMicros?: string }>;
35
+ }
36
+
37
+ export function toWooOrderPayload(envelope: OrderCreateEnvelope, local?: WooLocalOrder, options?: { paymentsList?: boolean }): { payload: Record<string, unknown> } | { error: { code: string; message: string } } {
29
38
  const p = envelope.payload;
30
- if (p.pricesIncludeTax || p.lines.some((line) => line.taxInclusive === true)) {
31
- return { error: { code: 'unsupported_tax_mode', message: 'Prices that include tax are not supported for WooCommerce yet.' } };
32
- }
33
- if (p.payments.length !== 1) {
39
+ if (p.payments.length === 0 || (p.payments.length > 1 && !options?.paymentsList)) {
34
40
  return { error: { code: 'invalid_payload', message: 'WooCommerce orders take one payment.' } };
35
41
  }
42
+ if (p.payments.length > 20) {
43
+ return { error: { code: 'invalid_payload', message: 'WooCommerce records at most 20 payments.' } };
44
+ }
45
+ if (p.payments.length > 1 && p.payments.reduce((sum, payment) => sum + payment.amountMinor, 0) !== p.totalMinor) {
46
+ return { error: { code: 'invalid_payload', message: 'Payments do not add up to the order total.' } };
47
+ }
36
48
  for (const line of p.lines) {
37
- if (!/^[1-9]\d*$/.test(line.variantId) || !Number.isSafeInteger(Number(line.variantId))) {
49
+ if (!line.custom && (!/^[1-9]\d*$/.test(line.variantId ?? '') || !Number.isSafeInteger(Number(line.variantId)))) {
38
50
  return { error: { code: 'invalid_payload', message: `Line ${line.clientLineId} has no WooCommerce product id.` } };
39
51
  }
40
52
  }
41
53
  const digits = p.display?.exponent ?? minorUnitDigits(p.currency);
42
54
  const major = (minor: number) => (minor / 10 ** digits).toFixed(digits);
43
- const cash = p.payments[0].method === 'cash';
55
+ const inclusiveNet = (netMicros: string | undefined, id: string) => {
56
+ if (netMicros === undefined) {
57
+ return { error: { code: 'unsupported_tax_mode', message: "Tax-inclusive prices need the till's WooCommerce tax figures; update the till." } };
58
+ }
59
+ const net = BigInt(netMicros), divisor = 10n ** BigInt(digits);
60
+ if (net < 0n) {
61
+ return { error: { code: 'invalid_payload', message: `Line ${id} has a negative net.` } };
62
+ }
63
+ if (net % divisor !== 0n) {
64
+ return { error: { code: 'invalid_payload', message: `Line ${id} has a net finer than 6 decimals.` } };
65
+ }
66
+ const micros = net / divisor;
67
+ return `${micros / 1000000n}.${(micros % 1000000n).toString().padStart(6, '0')}`;
68
+ };
69
+ const line_items = [];
70
+ for (const line of p.lines) {
71
+ let total = major(line.unitPriceMinor * line.quantity - (line.discountMinor ?? 0));
72
+ let subtotal = total;
73
+ if (line.taxInclusive ?? p.pricesIncludeTax) {
74
+ const net = inclusiveNet(local?.lines.find((stored) => stored.id === line.clientLineId)?.netMicros, line.clientLineId);
75
+ if (typeof net !== 'string') return net;
76
+ subtotal = total = net;
77
+ }
78
+ const custom = line.custom;
79
+ const price = major(line.unitPriceMinor);
80
+ line_items.push(custom ? {
81
+ product_id: 0, name: custom.name, ...(custom.sku ? { sku: custom.sku } : {}), quantity: line.quantity, subtotal, total,
82
+ tax_class: custom.taxClass ?? '', meta_data: [{ key: '_woocommerce_pos_data',
83
+ value: JSON.stringify({ price, regular_price: price, tax_status: custom.taxStatus }) }],
84
+ } : { product_id: Number(line.variantId), quantity: line.quantity, subtotal, total });
85
+ }
86
+ const fee_lines = [], shipping_lines = [];
87
+ for (const fee of p.fees ?? []) {
88
+ const total = p.pricesIncludeTax
89
+ ? inclusiveNet(local?.fees?.find((stored) => stored.id === fee.clientFeeId)?.netMicros, fee.clientFeeId) : major(fee.amountMinor);
90
+ if (typeof total !== 'string') return total;
91
+ fee_lines.push({ name: fee.name, total, tax_status: fee.taxStatus, tax_class: fee.taxClass ?? '' });
92
+ }
93
+ for (const shipping of p.shipping ?? []) {
94
+ const total = p.pricesIncludeTax
95
+ ? inclusiveNet(local?.shipping?.find((stored) => stored.id === shipping.clientShippingId)?.netMicros, shipping.clientShippingId) : major(shipping.amountMinor);
96
+ if (typeof total !== 'string') return total;
97
+ shipping_lines.push({ method_id: shipping.methodId ?? 'pos', method_title: shipping.name, total });
98
+ }
99
+ const primary = p.payments.reduce((largest, payment) => payment.amountMinor > largest.amountMinor ? payment : largest);
100
+ const cash = primary.method === 'cash';
101
+ const meta_data = [{ key: '_woocommerce_pos_uuid', value: p.clientOrderId }];
102
+ if (p.payments.length > 1) {
103
+ meta_data.push({ key: '_woocommerce_pos_payments', value: JSON.stringify(p.payments.map((payment) => ({
104
+ method: payment.method === 'cash' ? 'pos_cash' : 'pos_card',
105
+ title: payment.method === 'cash' ? 'Cash' : 'Card', amount: major(payment.amountMinor),
106
+ ...(payment.reference !== undefined ? { reference: payment.reference } : {}),
107
+ ...(payment.method === 'cash' && payment.tenderedMinor !== undefined ? { tendered: major(payment.tenderedMinor) } : {}),
108
+ ...(payment.method === 'cash' && payment.changeMinor !== undefined ? { change: major(payment.changeMinor) } : {}),
109
+ }))) });
110
+ }
44
111
  return { payload: {
45
112
  status: 'completed', set_paid: true, currency: p.currency,
46
113
  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 }],
114
+ line_items,
115
+ ...(fee_lines.length ? { fee_lines } : {}),
116
+ ...(shipping_lines.length ? { shipping_lines } : {}),
117
+ meta_data,
53
118
  ...(p.customer?.customerId && /^\d+$/.test(p.customer.customerId) ? { customer_id: Number(p.customer.customerId) } : {}),
54
119
  ...(p.customer?.email ? { billing: { email: p.customer.email } } : {}),
55
120
  } };
56
121
  }
57
122
 
58
- export function createWooCommandTransport(options: WooCommandTransportOptions): { send(batch: OrderCreateEnvelope[]): Promise<WooTransportOutcome> } {
123
+ export function createWooCommandTransport(options: WooCommandTransportOptions): {
124
+ send(batch: OrderCreateEnvelope[], context?: { local?: { orders?: ReadonlyMap<string, WooLocalOrder> } }): Promise<WooTransportOutcome>;
125
+ } {
59
126
  const fetch = options.fetch ?? globalThis.fetch;
60
127
  let baseUrl = options.baseUrl;
61
128
  while (baseUrl.endsWith('/')) baseUrl = baseUrl.slice(0, -1);
62
129
  return {
63
- async send(batch) {
130
+ async send(batch, context) {
131
+ const paymentsList = typeof options.acceptsPaymentsList === 'function'
132
+ ? await options.acceptsPaymentsList() : options.acceptsPaymentsList ?? false;
64
133
  const results: CommandResult[] = [];
65
134
  for (const envelope of batch) {
66
- const mapped = toWooOrderPayload(envelope);
135
+ const mapped = toWooOrderPayload(envelope, context?.local?.orders?.get(envelope.id), { paymentsList });
67
136
  if ('error' in mapped) {
68
137
  results.push({ id: envelope.id, status: 'rejected', error: mapped.error });
69
138
  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: v4's net discounts, and v5's fees, shipping and custom lines (WCPOS push/orders; orders #146, #161). */
5
+ export const WOO_ORDER_CREATE_VERSION = 5;
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';