@tallyui/connector-vendure 2.0.0 → 3.0.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/index.d.ts +25 -5
- package/dist/index.js +122 -13
- package/package.json +5 -5
- package/src/auth.ts +6 -1
- package/src/capabilities.ts +45 -0
- package/src/index.ts +21 -5
- package/src/replication/products.ts +29 -3
- package/src/schemas/products.ts +4 -2
- package/src/session-probe.ts +40 -0
- package/src/store-settings.ts +14 -3
- package/src/sync/products.ts +7 -1
- package/src/traits/product.ts +9 -1
package/README.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# TallyUI Vendure connector
|
|
2
|
+
|
|
3
|
+
## Errors
|
|
4
|
+
|
|
5
|
+
Requests with expired or rejected credentials reject with `ConnectorUnauthorizedError`, defined in `@tallyui/core` and re-exported by this connector. `error.status` is `401` (`code: 'unauthorized'`, sign in again) or `403` (`code: 'forbidden'`, signed in but not allowed: the pull retries on the store schedule; don't sign out); a signed-out Vendure session is always `401`.
|
|
6
|
+
|
|
7
|
+
Sign-in itself rejects with `SignInError`.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ConnectorAuth, ProductTraits, CollectionSync, ReplicationAdapter, StockReconcileAdapter, SyncContext, StoreSettingsChoice, StoreSettings, TallyConnector } from '@tallyui/core';
|
|
2
|
+
export { ConnectorUnauthorizedError } from '@tallyui/core';
|
|
2
3
|
import { RxJsonSchema } from 'rxdb';
|
|
3
4
|
|
|
4
5
|
declare const vendureSignIn: NonNullable<ConnectorAuth['signIn']>;
|
|
@@ -28,6 +29,19 @@ type VendureProductCheckpoint = {
|
|
|
28
29
|
passHighWater?: string;
|
|
29
30
|
passTotal?: number;
|
|
30
31
|
};
|
|
32
|
+
/** The server's updatedAt filters miss changes because it does not run in UTC (probeUpdatedAtSkew). */
|
|
33
|
+
declare class VendureTimezoneConfigError extends Error {
|
|
34
|
+
name: string;
|
|
35
|
+
readonly code: "store_misconfigured";
|
|
36
|
+
/** Only the store owner can fix it on the server, so the pull waits the store delay (`errorKind`). */
|
|
37
|
+
readonly fixedBy: "store";
|
|
38
|
+
/**
|
|
39
|
+
* The store-side remedy, for `SyncStatus`'s detail. The message's other remedy,
|
|
40
|
+
* `updatedAtSkewMs`, is a connector option on the till, so it stays in the message only.
|
|
41
|
+
*/
|
|
42
|
+
readonly fix = "run the Vendure server with its time zone set to UTC";
|
|
43
|
+
constructor();
|
|
44
|
+
}
|
|
31
45
|
/**
|
|
32
46
|
* Replication adapter for Vendure products.
|
|
33
47
|
*
|
|
@@ -71,7 +85,7 @@ declare const vendureStockReconcile: StockReconcileAdapter;
|
|
|
71
85
|
/**
|
|
72
86
|
* Reads the active channel's currency and tax-inclusivity (ADR-049), and the
|
|
73
87
|
* default tax zone's enabled, non-customer-group rates keyed by tax category
|
|
74
|
-
* id, in integer ppm rounded once. Read-only (ADR-048). Ignores `choice`:
|
|
88
|
+
* id, in integer ppm rounded once, with their rate names for grouping. Read-only (ADR-048). Ignores `choice`:
|
|
75
89
|
* Vendure's channel is already chosen by the `vendure-token` header sent
|
|
76
90
|
* with every request, so there is no `pricingContext` either.
|
|
77
91
|
*/
|
|
@@ -88,16 +102,18 @@ declare function vendureGlobalStockSettings(context: SyncContext): Promise<{
|
|
|
88
102
|
}>;
|
|
89
103
|
|
|
90
104
|
/**
|
|
91
|
-
* Vendure connector for Tally UI.
|
|
105
|
+
* Vendure connector for Tally UI. Build one per store session, anew on each sign-in or store change:
|
|
106
|
+
* each instance owns its reconcile feed, whose queued work must never reach another store's database (#307).
|
|
92
107
|
*
|
|
93
108
|
* Connects to Vendure backends via the Admin GraphQL API.
|
|
94
109
|
* Products are stored in RxDB using a schema that mirrors the Vendure API shape.
|
|
95
110
|
*
|
|
96
111
|
* ```ts
|
|
97
|
-
* import {
|
|
112
|
+
* import { createVendureConnector } from '@tallyui/connector-vendure';
|
|
98
113
|
* import { ConnectorProvider } from '@tallyui/core';
|
|
99
114
|
*
|
|
100
|
-
*
|
|
115
|
+
* const connector = useMemo(() => createVendureConnector({ pricesIncludeTax }), [backendUrl, pricesIncludeTax]);
|
|
116
|
+
* <ConnectorProvider connector={connector}>
|
|
101
117
|
* <App />
|
|
102
118
|
* </ConnectorProvider>
|
|
103
119
|
* ```
|
|
@@ -116,6 +132,10 @@ declare const createVendureConnector: (options?: {
|
|
|
116
132
|
globalTrackInventory?: boolean;
|
|
117
133
|
globalOutOfStockThreshold?: number;
|
|
118
134
|
}) => TallyConnector;
|
|
135
|
+
/**
|
|
136
|
+
* @deprecated One instance for the whole app: a store switch can leak queued reconcile work across stores.
|
|
137
|
+
* Use createVendureConnector() per store session. Removed in 4.0.
|
|
138
|
+
*/
|
|
119
139
|
declare const vendureConnector: TallyConnector;
|
|
120
140
|
|
|
121
|
-
export { createVendureConnector, createVendureProductReplication, createVendureVariantFeedReplication, vendureAuth, vendureConnector, vendureGlobalStockSettings, vendureProductReplication, vendureProductSchema, vendureProductSync, vendureProductTraits, vendureSignIn, vendureStockReconcile, vendureStoreSettings };
|
|
141
|
+
export { VendureTimezoneConfigError, createVendureConnector, createVendureProductReplication, createVendureVariantFeedReplication, vendureAuth, vendureConnector, vendureGlobalStockSettings, vendureProductReplication, vendureProductSchema, vendureProductSync, vendureProductTraits, vendureSignIn, vendureStockReconcile, vendureStoreSettings };
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,66 @@ import { combinePullAdapters, createReconcileFeed } from "@tallyui/core";
|
|
|
3
3
|
|
|
4
4
|
// src/auth.ts
|
|
5
5
|
import { SignInError } from "@tallyui/core";
|
|
6
|
+
|
|
7
|
+
// src/capabilities.ts
|
|
8
|
+
import { ConnectorUnauthorizedError as ConnectorUnauthorizedError2, parseInfoCapabilities } from "@tallyui/core";
|
|
9
|
+
|
|
10
|
+
// src/session-probe.ts
|
|
11
|
+
import { ConnectorUnauthorizedError } from "@tallyui/core";
|
|
12
|
+
async function signedOut(context) {
|
|
13
|
+
try {
|
|
14
|
+
const res = await fetch(`${context.baseUrl}/admin-api`, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { "Content-Type": "application/json", ...context.headers },
|
|
17
|
+
body: JSON.stringify({ query: "query { activeAdministrator { id } }" }),
|
|
18
|
+
signal: context.signal
|
|
19
|
+
});
|
|
20
|
+
if (!res.ok) return void 0;
|
|
21
|
+
const body = await res.json();
|
|
22
|
+
if (body.errors?.length) return void 0;
|
|
23
|
+
if (body.data?.activeAdministrator === null) return true;
|
|
24
|
+
return body.data?.activeAdministrator?.id != null ? false : void 0;
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (context.signal?.aborted) throw error;
|
|
27
|
+
return void 0;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function forbiddenError(context, forbidden) {
|
|
31
|
+
const out = await signedOut(context);
|
|
32
|
+
if (out === true) return new ConnectorUnauthorizedError(`Vendure GraphQL error: ${forbidden[0].message}`, 401);
|
|
33
|
+
const what = forbidden.map(({ message, path }) => path?.length ? `${path.join(".")}: ${message}` : message).join("; ");
|
|
34
|
+
if (out === false) return new Error(`Vendure GraphQL error: FORBIDDEN: the store refused this request although the session is signed in (a permission is missing): ${what}`);
|
|
35
|
+
return new Error(`Vendure GraphQL error: FORBIDDEN: ${what} (the session check failed, so this may be transient)`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/capabilities.ts
|
|
39
|
+
var CAPABILITIES_PATH = "/tally/v1/info";
|
|
40
|
+
async function readVendureCapabilities(baseUrl, headers, init = {}) {
|
|
41
|
+
const doFetch = init.fetch ?? fetch;
|
|
42
|
+
let res;
|
|
43
|
+
try {
|
|
44
|
+
res = await doFetch(`${baseUrl}${CAPABILITIES_PATH}`, { method: "GET", headers, signal: init.signal });
|
|
45
|
+
} catch {
|
|
46
|
+
return void 0;
|
|
47
|
+
}
|
|
48
|
+
if (res.status === 401 || res.status === 403) {
|
|
49
|
+
const out = await signedOut({ connectorId: "vendure", baseUrl, headers, signal: init.signal });
|
|
50
|
+
if (out === true) throw new ConnectorUnauthorizedError2(`Vendure refused ${CAPABILITIES_PATH}: the session is signed out`, 401);
|
|
51
|
+
if (out === false) throw new Error(`Vendure refused ${CAPABILITIES_PATH} although the session is signed in: the CreateOrder permission is missing (HTTP ${res.status})`);
|
|
52
|
+
return void 0;
|
|
53
|
+
}
|
|
54
|
+
if (res.status === 404) return { orderCreate: 1 };
|
|
55
|
+
if (!res.ok) return void 0;
|
|
56
|
+
let body;
|
|
57
|
+
try {
|
|
58
|
+
body = await res.json();
|
|
59
|
+
} catch {
|
|
60
|
+
return void 0;
|
|
61
|
+
}
|
|
62
|
+
return parseInfoCapabilities(body, (reason) => console.warn(`@tallyui/connector-vendure: ${reason}`));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/auth.ts
|
|
6
66
|
var LOGIN = `mutation Login($email: String!, $password: String!) {
|
|
7
67
|
login(username: $email, password: $password) {
|
|
8
68
|
__typename
|
|
@@ -42,7 +102,8 @@ var vendureSignIn = async (baseUrl, { email, password }, init = {}) => {
|
|
|
42
102
|
if (!token) {
|
|
43
103
|
throw new SignInError("unsupported", "Vendure signed in but sent no 'vendure-auth-token' header. Add 'bearer' to the server's authOptions.tokenMethod. This connector reads the default header name, so a server that renames authOptions.authTokenHeaderKey also gives this error.");
|
|
44
104
|
}
|
|
45
|
-
|
|
105
|
+
const capabilities = await readVendureCapabilities(baseUrl, { Authorization: `Bearer ${token}` }, { fetch: doFetch, signal: init.signal }).catch(() => void 0);
|
|
106
|
+
return { token, expiresAt: void 0, capabilities };
|
|
46
107
|
};
|
|
47
108
|
var warnedAuthToken = false;
|
|
48
109
|
var vendureAuth = {
|
|
@@ -72,8 +133,9 @@ var vendureAuth = {
|
|
|
72
133
|
// src/schemas/products.ts
|
|
73
134
|
var vendureProductSchema = {
|
|
74
135
|
// 1: declares variant trackInventory/outOfStockThreshold/useGlobalOutOfStockThreshold/enabled
|
|
75
|
-
// (backlog 28). A bump drops and resyncs
|
|
76
|
-
|
|
136
|
+
// (backlog 28). 2: declares variant taxCategory { id } (#288). A bump drops and resyncs
|
|
137
|
+
// (ConnectorSchemas, ADR-060 amendment 9).
|
|
138
|
+
version: 2,
|
|
77
139
|
primaryKey: "id",
|
|
78
140
|
type: "object",
|
|
79
141
|
properties: {
|
|
@@ -174,7 +236,8 @@ var vendureProductSchema = {
|
|
|
174
236
|
}
|
|
175
237
|
}
|
|
176
238
|
},
|
|
177
|
-
customFields: { type: "object" }
|
|
239
|
+
customFields: { type: "object" },
|
|
240
|
+
taxCategory: { type: ["object", "null"], properties: { id: { type: "string" } } }
|
|
178
241
|
}
|
|
179
242
|
}
|
|
180
243
|
},
|
|
@@ -235,6 +298,12 @@ function createVendureProductTraits(barcodeField, stockLocationId, pricesInclude
|
|
|
235
298
|
prices: traits.getPrices({ variants: [variant] }, context),
|
|
236
299
|
stock: traits.getStock({ variants: [variant] })
|
|
237
300
|
})),
|
|
301
|
+
// The variant's tax category id: store-settings.ts keys taxRatesPpm by the same id. No variant id: the default
|
|
302
|
+
// (first live) variant, as getPrices.
|
|
303
|
+
getTaxClass: (doc, variantId) => {
|
|
304
|
+
const variant = variantId === void 0 ? liveVariants(doc)[0] : (doc.variants ?? []).find((v) => String(v.id) === variantId);
|
|
305
|
+
return variant?.taxCategory?.id == null ? void 0 : String(variant.taxCategory.id);
|
|
306
|
+
},
|
|
238
307
|
getStock: (doc) => productStock(liveVariants(doc).map((variant) => variantStock(variant, stockLocationId, globalTrackInventory, globalOutOfStockThreshold))),
|
|
239
308
|
getPrice: (doc) => {
|
|
240
309
|
const amount = liveVariants(doc)[0]?.[priceField];
|
|
@@ -272,7 +341,7 @@ function createVendureProductTraits(barcodeField, stockLocationId, pricesInclude
|
|
|
272
341
|
return "simple";
|
|
273
342
|
},
|
|
274
343
|
getBarcode: (doc) => {
|
|
275
|
-
return barcodeField ? doc
|
|
344
|
+
return barcodeField ? liveVariants(doc)[0]?.customFields?.[barcodeField] || void 0 : void 0;
|
|
276
345
|
},
|
|
277
346
|
getCategoryNames: (doc) => (doc.collections ?? []).map((c) => c.name).filter(Boolean)
|
|
278
347
|
};
|
|
@@ -281,6 +350,7 @@ function createVendureProductTraits(barcodeField, stockLocationId, pricesInclude
|
|
|
281
350
|
var vendureProductTraits = createVendureProductTraits();
|
|
282
351
|
|
|
283
352
|
// src/sync/products.ts
|
|
353
|
+
import { ConnectorUnauthorizedError as ConnectorUnauthorizedError3 } from "@tallyui/core";
|
|
284
354
|
var PRODUCT_LIST_QUERY = `
|
|
285
355
|
query GetProducts($options: ProductListOptions) {
|
|
286
356
|
products(options: $options) {
|
|
@@ -395,12 +465,16 @@ async function gql(context, query2, variables) {
|
|
|
395
465
|
body: JSON.stringify({ query: query2, variables }),
|
|
396
466
|
signal: context.signal
|
|
397
467
|
});
|
|
468
|
+
if (res.status === 401 || res.status === 403) throw new ConnectorUnauthorizedError3(`Vendure API error: ${res.status}`, res.status);
|
|
398
469
|
if (!res.ok) throw new Error(`Vendure API error: ${res.status}`);
|
|
399
|
-
|
|
470
|
+
const body = await res.json();
|
|
471
|
+
const forbidden = body.errors?.filter((error) => error.extensions?.code === "FORBIDDEN") ?? [];
|
|
472
|
+
if (forbidden.length) throw await forbiddenError(context, forbidden);
|
|
473
|
+
return body;
|
|
400
474
|
}
|
|
401
475
|
|
|
402
476
|
// src/replication/products.ts
|
|
403
|
-
import { compareIds } from "@tallyui/core";
|
|
477
|
+
import { compareIds, ConnectorUnauthorizedError as ConnectorUnauthorizedError4 } from "@tallyui/core";
|
|
404
478
|
var PRODUCT_LIST_QUERY2 = (barcodeField) => `
|
|
405
479
|
query GetProducts($options: ProductListOptions) {
|
|
406
480
|
products(options: $options) {
|
|
@@ -430,6 +504,7 @@ var PRODUCT_LIST_QUERY2 = (barcodeField) => `
|
|
|
430
504
|
enabled
|
|
431
505
|
featuredAsset { id preview }
|
|
432
506
|
options { id name code }
|
|
507
|
+
taxCategory { id }
|
|
433
508
|
${barcodeField ? `customFields { ${barcodeField} }` : ""}
|
|
434
509
|
}
|
|
435
510
|
}
|
|
@@ -458,12 +533,30 @@ async function gql2(context, query2, variables) {
|
|
|
458
533
|
if (!res.ok) {
|
|
459
534
|
const body2 = await res.json().catch(() => void 0);
|
|
460
535
|
const message = body2?.errors?.[0]?.message;
|
|
536
|
+
if (res.status === 401 || res.status === 403) throw new ConnectorUnauthorizedError4(`Vendure API error: ${res.status}${message ? `: ${message}` : ""}`, res.status);
|
|
461
537
|
throw new Error(`Vendure API error: ${res.status}${message ? `: ${message}` : ""}`);
|
|
462
538
|
}
|
|
463
539
|
const body = await res.json();
|
|
540
|
+
const forbidden = body.errors?.filter((error) => error.extensions?.code === "FORBIDDEN") ?? [];
|
|
541
|
+
if (forbidden.length) throw await forbiddenError(context, forbidden);
|
|
464
542
|
if (body.errors?.length) throw new Error(`Vendure GraphQL error: ${body.errors[0].message}`);
|
|
465
543
|
return body;
|
|
466
544
|
}
|
|
545
|
+
var TIMEZONE_MESSAGE = "Vendure updatedAt filters miss changes: run Vendure with TZ=UTC or set updatedAtSkewMs to at least the magnitude of the server UTC offset in milliseconds.";
|
|
546
|
+
var VendureTimezoneConfigError = class extends Error {
|
|
547
|
+
name = "VendureTimezoneConfigError";
|
|
548
|
+
code = "store_misconfigured";
|
|
549
|
+
/** Only the store owner can fix it on the server, so the pull waits the store delay (`errorKind`). */
|
|
550
|
+
fixedBy = "store";
|
|
551
|
+
/**
|
|
552
|
+
* The store-side remedy, for `SyncStatus`'s detail. The message's other remedy,
|
|
553
|
+
* `updatedAtSkewMs`, is a connector option on the till, so it stays in the message only.
|
|
554
|
+
*/
|
|
555
|
+
fix = "run the Vendure server with its time zone set to UTC";
|
|
556
|
+
constructor() {
|
|
557
|
+
super(TIMEZONE_MESSAGE);
|
|
558
|
+
}
|
|
559
|
+
};
|
|
467
560
|
async function probeUpdatedAtSkew(passHighWater, updatedAtSkewMs, fetchTotal, remark) {
|
|
468
561
|
for (const overlap of [1, 0]) {
|
|
469
562
|
if (overlap === 0 && updatedAtSkewMs > 0) continue;
|
|
@@ -477,7 +570,8 @@ async function probeUpdatedAtSkew(passHighWater, updatedAtSkewMs, fetchTotal, re
|
|
|
477
570
|
}
|
|
478
571
|
}
|
|
479
572
|
if (overlap === 1 && total === 0) {
|
|
480
|
-
throw new
|
|
573
|
+
if (remark) throw new VendureTimezoneConfigError();
|
|
574
|
+
throw new Error(TIMEZONE_MESSAGE);
|
|
481
575
|
}
|
|
482
576
|
if (overlap === 0 && total > 0) {
|
|
483
577
|
console.warn("Vendure updatedAt filters over-fetch because the server is not in UTC.");
|
|
@@ -730,7 +824,7 @@ var CHANNEL_AND_CATEGORIES_QUERY = `
|
|
|
730
824
|
var TAX_RATES_QUERY = `
|
|
731
825
|
query StoreSettingsTaxRates($zoneId: String!) {
|
|
732
826
|
taxRates(options: { take: 1000, filter: { zoneId: { eq: $zoneId } } }) {
|
|
733
|
-
items { enabled value category { id } customerGroup { id } }
|
|
827
|
+
items { name enabled value category { id } customerGroup { id } }
|
|
734
828
|
}
|
|
735
829
|
}
|
|
736
830
|
`;
|
|
@@ -752,19 +846,28 @@ var vendureStoreSettings = async (context, _choice) => {
|
|
|
752
846
|
rateItems = ratesBody.data?.taxRates?.items ?? [];
|
|
753
847
|
}
|
|
754
848
|
const byCategory = /* @__PURE__ */ new Map();
|
|
849
|
+
const namesByCategory = /* @__PURE__ */ new Map();
|
|
755
850
|
for (const rate of rateItems) {
|
|
756
851
|
if (!rate.enabled || rate.customerGroup || !rate.category) continue;
|
|
757
852
|
byCategory.set(rate.category.id, Math.round(rate.value * 1e4));
|
|
853
|
+
if (typeof rate.name === "string" && rate.name) namesByCategory.set(rate.category.id, rate.name);
|
|
854
|
+
else namesByCategory.delete(rate.category.id);
|
|
758
855
|
}
|
|
759
856
|
const defaultCategory = categories.find((c) => c.isDefault) ?? categories[0];
|
|
857
|
+
const taxRateCodes = {};
|
|
858
|
+
const defaultName = defaultCategory && namesByCategory.get(defaultCategory.id);
|
|
859
|
+
if (defaultName) taxRateCodes.default = defaultName;
|
|
860
|
+
for (const [categoryId, name] of namesByCategory) taxRateCodes[categoryId] = name;
|
|
760
861
|
const taxRatesPpm = {
|
|
761
862
|
default: defaultCategory ? byCategory.get(defaultCategory.id) ?? 0 : 0
|
|
762
863
|
};
|
|
763
864
|
for (const [categoryId, ppm] of byCategory) taxRatesPpm[categoryId] = ppm;
|
|
865
|
+
for (const category of categories) taxRatesPpm[category.id] ??= 0;
|
|
764
866
|
return {
|
|
765
867
|
currency: channel.defaultCurrencyCode,
|
|
766
868
|
pricesIncludeTax: channel.pricesIncludeTax,
|
|
767
|
-
taxRatesPpm
|
|
869
|
+
taxRatesPpm,
|
|
870
|
+
...Object.keys(taxRateCodes).length > 0 && { taxRateCodes }
|
|
768
871
|
};
|
|
769
872
|
};
|
|
770
873
|
|
|
@@ -780,6 +883,8 @@ async function vendureGlobalStockSettings(context) {
|
|
|
780
883
|
}
|
|
781
884
|
|
|
782
885
|
// src/index.ts
|
|
886
|
+
import { ConnectorUnauthorizedError as ConnectorUnauthorizedError5 } from "@tallyui/core";
|
|
887
|
+
var VENDURE_IDS_PER_REQUEST = 1e3;
|
|
783
888
|
var createVendureConnector = (options = {}) => {
|
|
784
889
|
const idFeed = createReconcileFeed({ fetchByIds: createFetchByIds(options.barcodeField) });
|
|
785
890
|
return {
|
|
@@ -814,14 +919,18 @@ var createVendureConnector = (options = {}) => {
|
|
|
814
919
|
},
|
|
815
920
|
reconcile: {
|
|
816
921
|
stock: vendureStockReconcile,
|
|
817
|
-
ids: { fetchPages, variantIds, enqueue: idFeed.enqueue },
|
|
818
|
-
prices: { fetchPages: fetchPages2, fingerprint, enqueue: idFeed.enqueue }
|
|
922
|
+
ids: { fetchPages, variantIds, enqueue: idFeed.enqueue, refetchBatchSize: VENDURE_IDS_PER_REQUEST },
|
|
923
|
+
prices: { fetchPages: fetchPages2, fingerprint, enqueue: idFeed.enqueue, refetchBatchSize: VENDURE_IDS_PER_REQUEST }
|
|
819
924
|
},
|
|
820
|
-
storeSettings: vendureStoreSettings
|
|
925
|
+
storeSettings: vendureStoreSettings,
|
|
926
|
+
// Re-reads a restored session's capabilities; with an API key and no sign-in, the only reader.
|
|
927
|
+
capabilities: (context) => readVendureCapabilities(context.baseUrl, context.headers, { signal: context.signal })
|
|
821
928
|
};
|
|
822
929
|
};
|
|
823
930
|
var vendureConnector = createVendureConnector();
|
|
824
931
|
export {
|
|
932
|
+
ConnectorUnauthorizedError5 as ConnectorUnauthorizedError,
|
|
933
|
+
VendureTimezoneConfigError,
|
|
825
934
|
createVendureConnector,
|
|
826
935
|
createVendureProductReplication,
|
|
827
936
|
createVendureVariantFeedReplication,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tallyui/connector-vendure",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0-next.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Vendure connector for Tally UI",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -26,12 +26,12 @@
|
|
|
26
26
|
"directory": "connectors/vendure"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
|
-
"@tallyui/core": "
|
|
29
|
+
"@tallyui/core": "^3.0.0-next.1"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@tallyui/database": "
|
|
33
|
-
"@tallyui/pos": "
|
|
34
|
-
"rxdb": "
|
|
32
|
+
"@tallyui/database": "3.0.0-next.1",
|
|
33
|
+
"@tallyui/pos": "3.0.0-next.1",
|
|
34
|
+
"rxdb": "17.5.0"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "tsup",
|
package/src/auth.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { SignInError, type ConnectorAuth } from '@tallyui/core';
|
|
2
2
|
|
|
3
|
+
import { readVendureCapabilities } from './capabilities';
|
|
4
|
+
|
|
3
5
|
const LOGIN = `mutation Login($email: String!, $password: String!) {
|
|
4
6
|
login(username: $email, password: $password) {
|
|
5
7
|
__typename
|
|
@@ -43,7 +45,10 @@ export const vendureSignIn: NonNullable<ConnectorAuth['signIn']> = async (baseUr
|
|
|
43
45
|
if (!token) {
|
|
44
46
|
throw new SignInError('unsupported', "Vendure signed in but sent no 'vendure-auth-token' header. Add 'bearer' to the server's authOptions.tokenMethod. This connector reads the default header name, so a server that renames authOptions.authTokenHeaderKey also gives this error.");
|
|
45
47
|
}
|
|
46
|
-
|
|
48
|
+
// A capabilities read that fails never fails the sign-in: the capabilities are then unknown.
|
|
49
|
+
const capabilities = await readVendureCapabilities(baseUrl, { Authorization: `Bearer ${token}` }, { fetch: doFetch, signal: init.signal })
|
|
50
|
+
.catch(() => undefined);
|
|
51
|
+
return { token, expiresAt: undefined, capabilities };
|
|
47
52
|
};
|
|
48
53
|
|
|
49
54
|
// Warns once per module lifetime when a caller still relies on the deprecated auth_token alias.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ConnectorUnauthorizedError, parseInfoCapabilities, type ServerCapabilities } from '@tallyui/core';
|
|
2
|
+
|
|
3
|
+
import { signedOut } from './session-probe';
|
|
4
|
+
|
|
5
|
+
/** The plugin's capability endpoint on Vendure's REST server (ADR-062), relative to the base URL. */
|
|
6
|
+
export const CAPABILITIES_PATH = '/tally/v1/info';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Reads the store's contract capabilities (ADR-062) and `taxRounding` (#287) with the
|
|
10
|
+
* connector's own headers. A 404 means an old plugin and gives the default; a network failure,
|
|
11
|
+
* non-2xx other than 404/401/403, non-JSON 2xx or malformed `taxRounding` is unknown and gives
|
|
12
|
+
* `undefined`. The route answers a dead session and a missing
|
|
13
|
+
* `CreateOrder` permission alike with 403, so a 401/403 asks who is signed in (#279): nobody
|
|
14
|
+
* throws `ConnectorUnauthorizedError`, an administrator throws a plain Error naming the
|
|
15
|
+
* permission, and a failed check is unknown.
|
|
16
|
+
*/
|
|
17
|
+
export async function readVendureCapabilities(
|
|
18
|
+
baseUrl: string,
|
|
19
|
+
headers: Record<string, string>,
|
|
20
|
+
init: { fetch?: typeof fetch; signal?: AbortSignal } = {},
|
|
21
|
+
): Promise<ServerCapabilities | undefined> {
|
|
22
|
+
const doFetch = init.fetch ?? fetch;
|
|
23
|
+
let res: Response;
|
|
24
|
+
try {
|
|
25
|
+
res = await doFetch(`${baseUrl}${CAPABILITIES_PATH}`, { method: 'GET', headers, signal: init.signal });
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
if (res.status === 401 || res.status === 403) {
|
|
30
|
+
const out = await signedOut({ connectorId: 'vendure', baseUrl, headers, signal: init.signal });
|
|
31
|
+
// The probe confirms sign-out; the route's 403 also covers missing permissions.
|
|
32
|
+
if (out === true) throw new ConnectorUnauthorizedError(`Vendure refused ${CAPABILITIES_PATH}: the session is signed out`, 401);
|
|
33
|
+
if (out === false) throw new Error(`Vendure refused ${CAPABILITIES_PATH} although the session is signed in: the CreateOrder permission is missing (HTTP ${res.status})`);
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
if (res.status === 404) return { orderCreate: 1 };
|
|
37
|
+
if (!res.ok) return undefined;
|
|
38
|
+
let body: unknown;
|
|
39
|
+
try {
|
|
40
|
+
body = await res.json();
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
return parseInfoCapabilities(body, (reason) => console.warn(`@tallyui/connector-vendure: ${reason}`));
|
|
45
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { combinePullAdapters, createReconcileFeed, type TallyConnector } from '@tallyui/core';
|
|
2
2
|
|
|
3
3
|
import { vendureAuth } from './auth';
|
|
4
|
+
import { readVendureCapabilities } from './capabilities';
|
|
4
5
|
import { vendureProductSchema } from './schemas/products';
|
|
5
6
|
import { createVendureProductTraits } from './traits/product';
|
|
6
7
|
import { createVendureProductSync } from './sync/products';
|
|
@@ -12,17 +13,22 @@ import { fetchPages as fetchPricePages, fingerprint as priceFingerprint } from '
|
|
|
12
13
|
import { vendureStoreSettings } from './store-settings';
|
|
13
14
|
import { vendureGlobalStockSettings } from './global-settings';
|
|
14
15
|
|
|
16
|
+
/** Ids per fetchByIds request: it sends the reconcile feed's whole chunk (at most 1,000 ids, ADR-060) in one query. */
|
|
17
|
+
const VENDURE_IDS_PER_REQUEST = 1000;
|
|
18
|
+
|
|
15
19
|
/**
|
|
16
|
-
* Vendure connector for Tally UI.
|
|
20
|
+
* Vendure connector for Tally UI. Build one per store session, anew on each sign-in or store change:
|
|
21
|
+
* each instance owns its reconcile feed, whose queued work must never reach another store's database (#307).
|
|
17
22
|
*
|
|
18
23
|
* Connects to Vendure backends via the Admin GraphQL API.
|
|
19
24
|
* Products are stored in RxDB using a schema that mirrors the Vendure API shape.
|
|
20
25
|
*
|
|
21
26
|
* ```ts
|
|
22
|
-
* import {
|
|
27
|
+
* import { createVendureConnector } from '@tallyui/connector-vendure';
|
|
23
28
|
* import { ConnectorProvider } from '@tallyui/core';
|
|
24
29
|
*
|
|
25
|
-
*
|
|
30
|
+
* const connector = useMemo(() => createVendureConnector({ pricesIncludeTax }), [backendUrl, pricesIncludeTax]);
|
|
31
|
+
* <ConnectorProvider connector={connector}>
|
|
26
32
|
* <App />
|
|
27
33
|
* </ConnectorProvider>
|
|
28
34
|
* ```
|
|
@@ -74,14 +80,21 @@ export const createVendureConnector = (options: {
|
|
|
74
80
|
|
|
75
81
|
reconcile: {
|
|
76
82
|
stock: vendureStockReconcile,
|
|
77
|
-
ids: { fetchPages, variantIds, enqueue: idFeed.enqueue },
|
|
78
|
-
prices: { fetchPages: fetchPricePages, fingerprint: priceFingerprint, enqueue: idFeed.enqueue },
|
|
83
|
+
ids: { fetchPages, variantIds, enqueue: idFeed.enqueue, refetchBatchSize: VENDURE_IDS_PER_REQUEST },
|
|
84
|
+
prices: { fetchPages: fetchPricePages, fingerprint: priceFingerprint, enqueue: idFeed.enqueue, refetchBatchSize: VENDURE_IDS_PER_REQUEST },
|
|
79
85
|
},
|
|
80
86
|
|
|
81
87
|
storeSettings: vendureStoreSettings,
|
|
88
|
+
|
|
89
|
+
// Re-reads a restored session's capabilities; with an API key and no sign-in, the only reader.
|
|
90
|
+
capabilities: (context) => readVendureCapabilities(context.baseUrl, context.headers, { signal: context.signal }),
|
|
82
91
|
};
|
|
83
92
|
};
|
|
84
93
|
|
|
94
|
+
/**
|
|
95
|
+
* @deprecated One instance for the whole app: a store switch can leak queued reconcile work across stores.
|
|
96
|
+
* Use createVendureConnector() per store session. Removed in 4.0.
|
|
97
|
+
*/
|
|
85
98
|
export const vendureConnector = createVendureConnector();
|
|
86
99
|
|
|
87
100
|
// Re-export pieces for advanced usage
|
|
@@ -94,3 +107,6 @@ export { createVendureVariantFeedReplication } from './replication/variant-feed'
|
|
|
94
107
|
export { vendureStockReconcile } from './reconcile/stock';
|
|
95
108
|
export { vendureStoreSettings } from './store-settings';
|
|
96
109
|
export { vendureGlobalStockSettings } from './global-settings';
|
|
110
|
+
|
|
111
|
+
export { ConnectorUnauthorizedError } from '@tallyui/core';
|
|
112
|
+
export { VendureTimezoneConfigError } from './replication/products';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { compareIds } from '@tallyui/core';
|
|
1
|
+
import { compareIds, ConnectorUnauthorizedError } from '@tallyui/core';
|
|
2
2
|
import type { ReplicationAdapter, SyncContext } from '@tallyui/core';
|
|
3
3
|
import { vendureProductSchema } from '../schemas/products';
|
|
4
|
+
import { forbiddenError } from '../session-probe';
|
|
4
5
|
|
|
5
6
|
export type VendureProductCheckpoint = {
|
|
6
7
|
skip: number;
|
|
@@ -39,6 +40,7 @@ export const PRODUCT_LIST_QUERY = (barcodeField?: string) => `
|
|
|
39
40
|
enabled
|
|
40
41
|
featuredAsset { id preview }
|
|
41
42
|
options { id name code }
|
|
43
|
+
taxCategory { id }
|
|
42
44
|
${barcodeField ? `customFields { ${barcodeField} }` : ''}
|
|
43
45
|
}
|
|
44
46
|
}
|
|
@@ -65,7 +67,7 @@ export async function gql(
|
|
|
65
67
|
query: string,
|
|
66
68
|
variables?: Record<string, any>,
|
|
67
69
|
): Promise<any> {
|
|
68
|
-
type GqlBody = { data?: any; errors?: Array<{ message?: string }> };
|
|
70
|
+
type GqlBody = { data?: any; errors?: Array<{ message?: string; path?: Array<string | number>; extensions?: { code?: string } }> };
|
|
69
71
|
const res = await fetch(`${context.baseUrl}/admin-api`, {
|
|
70
72
|
method: 'POST',
|
|
71
73
|
headers: {
|
|
@@ -79,13 +81,35 @@ export async function gql(
|
|
|
79
81
|
if (!res.ok) {
|
|
80
82
|
const body = await res.json().catch(() => undefined) as GqlBody | undefined;
|
|
81
83
|
const message = body?.errors?.[0]?.message;
|
|
84
|
+
if (res.status === 401 || res.status === 403) throw new ConnectorUnauthorizedError(`Vendure API error: ${res.status}${message ? `: ${message}` : ''}`, res.status as 401 | 403);
|
|
82
85
|
throw new Error(`Vendure API error: ${res.status}${message ? `: ${message}` : ''}`);
|
|
83
86
|
}
|
|
84
87
|
const body = await res.json() as GqlBody;
|
|
88
|
+
const forbidden = body.errors?.filter((error) => error.extensions?.code === 'FORBIDDEN') ?? [];
|
|
89
|
+
if (forbidden.length) throw await forbiddenError(context, forbidden);
|
|
85
90
|
if (body.errors?.length) throw new Error(`Vendure GraphQL error: ${body.errors[0].message}`);
|
|
86
91
|
return body;
|
|
87
92
|
}
|
|
88
93
|
|
|
94
|
+
const TIMEZONE_MESSAGE = 'Vendure updatedAt filters miss changes: run Vendure with TZ=UTC or set updatedAtSkewMs to at least the magnitude of the server UTC offset in milliseconds.';
|
|
95
|
+
|
|
96
|
+
/** The server's updatedAt filters miss changes because it does not run in UTC (probeUpdatedAtSkew). */
|
|
97
|
+
export class VendureTimezoneConfigError extends Error {
|
|
98
|
+
name = 'VendureTimezoneConfigError';
|
|
99
|
+
readonly code = 'store_misconfigured' as const;
|
|
100
|
+
/** Only the store owner can fix it on the server, so the pull waits the store delay (`errorKind`). */
|
|
101
|
+
readonly fixedBy = 'store' as const;
|
|
102
|
+
/**
|
|
103
|
+
* The store-side remedy, for `SyncStatus`'s detail. The message's other remedy,
|
|
104
|
+
* `updatedAtSkewMs`, is a connector option on the till, so it stays in the message only.
|
|
105
|
+
*/
|
|
106
|
+
readonly fix = 'run the Vendure server with its time zone set to UTC';
|
|
107
|
+
|
|
108
|
+
constructor() {
|
|
109
|
+
super(TIMEZONE_MESSAGE);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
89
113
|
/**
|
|
90
114
|
* Probes around a pass's high-water mark for the server's updatedAt skew
|
|
91
115
|
* (ADR-060). `fetchTotal(after)` runs the caller's own list query (products,
|
|
@@ -114,7 +138,9 @@ export async function probeUpdatedAtSkew(
|
|
|
114
138
|
}
|
|
115
139
|
}
|
|
116
140
|
if (overlap === 1 && total === 0) {
|
|
117
|
-
|
|
141
|
+
// Only a re-read rules out a mark deleted mid-probe; without one (the variant feed) the race stays a transient error.
|
|
142
|
+
if (remark) throw new VendureTimezoneConfigError();
|
|
143
|
+
throw new Error(TIMEZONE_MESSAGE);
|
|
118
144
|
}
|
|
119
145
|
if (overlap === 0 && total > 0) {
|
|
120
146
|
console.warn('Vendure updatedAt filters over-fetch because the server is not in UTC.');
|
package/src/schemas/products.ts
CHANGED
|
@@ -13,8 +13,9 @@ import type { RxJsonSchema } from 'rxdb';
|
|
|
13
13
|
*/
|
|
14
14
|
export const vendureProductSchema: RxJsonSchema<any> = {
|
|
15
15
|
// 1: declares variant trackInventory/outOfStockThreshold/useGlobalOutOfStockThreshold/enabled
|
|
16
|
-
// (backlog 28). A bump drops and resyncs
|
|
17
|
-
|
|
16
|
+
// (backlog 28). 2: declares variant taxCategory { id } (#288). A bump drops and resyncs
|
|
17
|
+
// (ConnectorSchemas, ADR-060 amendment 9).
|
|
18
|
+
version: 2,
|
|
18
19
|
primaryKey: 'id',
|
|
19
20
|
type: 'object',
|
|
20
21
|
properties: {
|
|
@@ -116,6 +117,7 @@ export const vendureProductSchema: RxJsonSchema<any> = {
|
|
|
116
117
|
},
|
|
117
118
|
},
|
|
118
119
|
customFields: { type: 'object' },
|
|
120
|
+
taxCategory: { type: ['object', 'null'], properties: { id: { type: 'string' } } },
|
|
119
121
|
},
|
|
120
122
|
},
|
|
121
123
|
},
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ConnectorUnauthorizedError } from '@tallyui/core';
|
|
2
|
+
import type { SyncContext } from '@tallyui/core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Vendure answers a dead session and a missing permission alike: HTTP 200 with GraphQL
|
|
6
|
+
* `FORBIDDEN` (#274). Asks who is signed in with the failed request's headers: `true` when
|
|
7
|
+
* nobody is, `false` when an administrator is, `undefined` when the check itself failed.
|
|
8
|
+
*/
|
|
9
|
+
export async function signedOut(context: SyncContext): Promise<boolean | undefined> {
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetch(`${context.baseUrl}/admin-api`, {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: { 'Content-Type': 'application/json', ...context.headers },
|
|
14
|
+
body: JSON.stringify({ query: 'query { activeAdministrator { id } }' }),
|
|
15
|
+
signal: context.signal,
|
|
16
|
+
});
|
|
17
|
+
if (!res.ok) return undefined;
|
|
18
|
+
const body = await res.json() as { data?: { activeAdministrator?: { id?: unknown } | null }; errors?: unknown[] };
|
|
19
|
+
if (body.errors?.length) return undefined;
|
|
20
|
+
if (body.data?.activeAdministrator === null) return true;
|
|
21
|
+
return body.data?.activeAdministrator?.id != null ? false : undefined;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (context.signal?.aborted) throw error;
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The error for a `FORBIDDEN` answer: only a signed-out session signs the till out. The plain
|
|
30
|
+
* errors name everything refused, in the answer's order (each GraphQL `path`, e.g. `product.variants`),
|
|
31
|
+
* for the store owner's log. One probe answers for the whole response.
|
|
32
|
+
*/
|
|
33
|
+
export async function forbiddenError(context: SyncContext, forbidden: Array<{ message?: string; path?: ReadonlyArray<string | number> }>): Promise<Error> {
|
|
34
|
+
const out = await signedOut(context);
|
|
35
|
+
// The probe confirmed a signed-out session even though Vendure answered FORBIDDEN.
|
|
36
|
+
if (out === true) return new ConnectorUnauthorizedError(`Vendure GraphQL error: ${forbidden[0].message}`, 401);
|
|
37
|
+
const what = forbidden.map(({ message, path }) => (path?.length ? `${path.join('.')}: ${message}` : message)).join('; ');
|
|
38
|
+
if (out === false) return new Error(`Vendure GraphQL error: FORBIDDEN: the store refused this request although the session is signed in (a permission is missing): ${what}`);
|
|
39
|
+
return new Error(`Vendure GraphQL error: FORBIDDEN: ${what} (the session check failed, so this may be transient)`);
|
|
40
|
+
}
|
package/src/store-settings.ts
CHANGED
|
@@ -13,14 +13,14 @@ const CHANNEL_AND_CATEGORIES_QUERY = `
|
|
|
13
13
|
const TAX_RATES_QUERY = `
|
|
14
14
|
query StoreSettingsTaxRates($zoneId: String!) {
|
|
15
15
|
taxRates(options: { take: 1000, filter: { zoneId: { eq: $zoneId } } }) {
|
|
16
|
-
items { enabled value category { id } customerGroup { id } }
|
|
16
|
+
items { name enabled value category { id } customerGroup { id } }
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
`;
|
|
20
20
|
|
|
21
21
|
type ActiveChannel = { defaultCurrencyCode: string; pricesIncludeTax: boolean; defaultTaxZone: { id: string } | null };
|
|
22
22
|
type TaxCategory = { id: string; isDefault: boolean };
|
|
23
|
-
type TaxRateItem = { enabled: boolean; value: number; category: { id: string } | null; customerGroup: { id: string } | null };
|
|
23
|
+
type TaxRateItem = { name?: string; enabled: boolean; value: number; category: { id: string } | null; customerGroup: { id: string } | null };
|
|
24
24
|
|
|
25
25
|
/** `gql` throws a plain `Error` for a non-OK response or a GraphQL `errors` body; both become `StoreSettingsError('failed')` here. */
|
|
26
26
|
async function query(context: SyncContext, source: string, variables?: Record<string, unknown>): Promise<any> {
|
|
@@ -34,7 +34,7 @@ async function query(context: SyncContext, source: string, variables?: Record<st
|
|
|
34
34
|
/**
|
|
35
35
|
* Reads the active channel's currency and tax-inclusivity (ADR-049), and the
|
|
36
36
|
* default tax zone's enabled, non-customer-group rates keyed by tax category
|
|
37
|
-
* id, in integer ppm rounded once. Read-only (ADR-048). Ignores `choice`:
|
|
37
|
+
* id, in integer ppm rounded once, with their rate names for grouping. Read-only (ADR-048). Ignores `choice`:
|
|
38
38
|
* Vendure's channel is already chosen by the `vendure-token` header sent
|
|
39
39
|
* with every request, so there is no `pricingContext` either.
|
|
40
40
|
*/
|
|
@@ -51,10 +51,13 @@ export const vendureStoreSettings = async (context: SyncContext, _choice?: Store
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
const byCategory = new Map<string, number>();
|
|
54
|
+
const namesByCategory = new Map<string, string>();
|
|
54
55
|
for (const rate of rateItems) {
|
|
55
56
|
if (!rate.enabled || rate.customerGroup || !rate.category) continue;
|
|
56
57
|
// Rounded once, here, at the connector's edge (a backend decimal percentage -> integer ppm).
|
|
57
58
|
byCategory.set(rate.category.id, Math.round(rate.value * 10_000));
|
|
59
|
+
if (typeof rate.name === 'string' && rate.name) namesByCategory.set(rate.category.id, rate.name);
|
|
60
|
+
else namesByCategory.delete(rate.category.id);
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
// Vendure's own fallback for a variant created without a category
|
|
@@ -65,14 +68,22 @@ export const vendureStoreSettings = async (context: SyncContext, _choice?: Store
|
|
|
65
68
|
// at all), `default` is 0 — that is what Vendure itself charges in that
|
|
66
69
|
// case, never a silent guess.
|
|
67
70
|
const defaultCategory = categories.find((c) => c.isDefault) ?? categories[0];
|
|
71
|
+
const taxRateCodes: Record<string, string> = {};
|
|
72
|
+
const defaultName = defaultCategory && namesByCategory.get(defaultCategory.id);
|
|
73
|
+
if (defaultName) taxRateCodes.default = defaultName;
|
|
74
|
+
for (const [categoryId, name] of namesByCategory) taxRateCodes[categoryId] = name;
|
|
68
75
|
const taxRatesPpm: { default: number; [taxClass: string]: number } = {
|
|
69
76
|
default: defaultCategory ? byCategory.get(defaultCategory.id) ?? 0 : 0,
|
|
70
77
|
};
|
|
71
78
|
for (const [categoryId, ppm] of byCategory) taxRatesPpm[categoryId] = ppm;
|
|
79
|
+
// A category with no enabled rate in the zone is charged 0 by Vendure, as for `default` above: an explicit 0, so the
|
|
80
|
+
// till never taxes it at the default rate (#288).
|
|
81
|
+
for (const category of categories) taxRatesPpm[category.id] ??= 0;
|
|
72
82
|
|
|
73
83
|
return {
|
|
74
84
|
currency: channel.defaultCurrencyCode,
|
|
75
85
|
pricesIncludeTax: channel.pricesIncludeTax,
|
|
76
86
|
taxRatesPpm,
|
|
87
|
+
...(Object.keys(taxRateCodes).length > 0 && { taxRateCodes }),
|
|
77
88
|
};
|
|
78
89
|
};
|
package/src/sync/products.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { ConnectorUnauthorizedError } from '@tallyui/core';
|
|
1
2
|
import type { CollectionSync, SyncContext } from '@tallyui/core';
|
|
3
|
+
import { forbiddenError } from '../session-probe';
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* GraphQL query fragments used by the Vendure sync.
|
|
@@ -150,6 +152,10 @@ async function gql(
|
|
|
150
152
|
signal: context.signal,
|
|
151
153
|
});
|
|
152
154
|
|
|
155
|
+
if (res.status === 401 || res.status === 403) throw new ConnectorUnauthorizedError(`Vendure API error: ${res.status}`, res.status as 401 | 403);
|
|
153
156
|
if (!res.ok) throw new Error(`Vendure API error: ${res.status}`);
|
|
154
|
-
|
|
157
|
+
const body: { data?: any; errors?: Array<{ message?: string; path?: Array<string | number>; extensions?: { code?: string } }> } = await res.json();
|
|
158
|
+
const forbidden = body.errors?.filter((error) => error.extensions?.code === 'FORBIDDEN') ?? [];
|
|
159
|
+
if (forbidden.length) throw await forbiddenError(context, forbidden);
|
|
160
|
+
return body;
|
|
155
161
|
}
|
package/src/traits/product.ts
CHANGED
|
@@ -110,6 +110,14 @@ const traits: ProductTraits = {
|
|
|
110
110
|
stock: traits.getStock({ variants: [variant] }),
|
|
111
111
|
})),
|
|
112
112
|
|
|
113
|
+
// The variant's tax category id: store-settings.ts keys taxRatesPpm by the same id. No variant id: the default
|
|
114
|
+
// (first live) variant, as getPrices.
|
|
115
|
+
getTaxClass: (doc, variantId) => {
|
|
116
|
+
const variant = variantId === undefined ? liveVariants(doc)[0]
|
|
117
|
+
: (doc.variants ?? []).find((v: any) => String(v.id) === variantId);
|
|
118
|
+
return variant?.taxCategory?.id == null ? undefined : String(variant.taxCategory.id);
|
|
119
|
+
},
|
|
120
|
+
|
|
113
121
|
getStock: (doc) => productStock(liveVariants(doc).map((variant: any) =>
|
|
114
122
|
variantStock(variant, stockLocationId, globalTrackInventory, globalOutOfStockThreshold))),
|
|
115
123
|
|
|
@@ -171,7 +179,7 @@ const traits: ProductTraits = {
|
|
|
171
179
|
|
|
172
180
|
getBarcode: (doc) => {
|
|
173
181
|
// Vendure has no native barcode — check custom fields
|
|
174
|
-
return barcodeField ? doc
|
|
182
|
+
return barcodeField ? liveVariants(doc)[0]?.customFields?.[barcodeField] || undefined : undefined;
|
|
175
183
|
},
|
|
176
184
|
|
|
177
185
|
getCategoryNames: (doc) =>
|