@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 +32 -4
- package/dist/index.js +241 -26
- package/package.json +3 -3
- package/src/commands/transport.ts +85 -16
- 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: 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[]
|
|
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
|
|
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,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.
|
|
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 =
|
|
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: "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 ??
|
|
874
|
+
const digits = p.display?.exponent ?? minorUnitDigits2(p.currency);
|
|
725
875
|
const major = (minor) => (minor / 10 ** digits).toFixed(digits);
|
|
726
|
-
const
|
|
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
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
29
|
+
"@tallyui/core": "^3.4.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@tallyui/database": "3.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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): {
|
|
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
|
}
|
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: 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
|
+
}
|
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';
|