@tallyui/connector-woocommerce 3.2.1 → 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 +24 -4
- package/dist/index.js +215 -25
- package/package.json +3 -3
- package/src/commands/transport.ts +61 -15
- package/src/index.ts +4 -0
- package/src/replication/products.ts +2 -1
- package/src/schemas/products.ts +8 -1
- package/src/store-settings.ts +128 -0
- package/src/traits/product.ts +16 -0
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[]
|
|
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
|
|
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:
|
|
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.
|
|
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 =
|
|
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.
|
|
714
|
-
return { error: { code: "
|
|
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
|
|
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 ??
|
|
877
|
+
const digits = p.display?.exponent ?? minorUnitDigits2(p.currency);
|
|
725
878
|
const major = (minor) => (minor / 10 ** digits).toFixed(digits);
|
|
726
|
-
const
|
|
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
|
|
734
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
29
|
+
"@tallyui/core": "^3.3.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@tallyui/database": "3.
|
|
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
|
|
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.
|
|
31
|
-
return { error: { code: '
|
|
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
|
|
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
|
|
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
|
|
48
|
-
|
|
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): {
|
|
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
|
}
|
package/src/schemas/products.ts
CHANGED
|
@@ -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:
|
|
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
|
+
}
|
package/src/traits/product.ts
CHANGED
|
@@ -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';
|