@tallyui/connector-vendure 1.0.0 → 2.0.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.
@@ -1,11 +1,16 @@
1
+ import { compareIds } from '@tallyui/core';
1
2
  import type { ReplicationAdapter, SyncContext } from '@tallyui/core';
3
+ import { vendureProductSchema } from '../schemas/products';
2
4
 
3
5
  export type VendureProductCheckpoint = {
4
6
  skip: number;
5
7
  updatedAt: string;
8
+ passMax?: string;
9
+ passHighWater?: string;
10
+ passTotal?: number;
6
11
  };
7
12
 
8
- const PRODUCT_LIST_QUERY = `
13
+ export const PRODUCT_LIST_QUERY = (barcodeField?: string) => `
9
14
  query GetProducts($options: ProductListOptions) {
10
15
  products(options: $options) {
11
16
  items {
@@ -27,12 +32,14 @@ const PRODUCT_LIST_QUERY = `
27
32
  price
28
33
  priceWithTax
29
34
  currencyCode
30
- stockLevel
31
- stockOnHand
35
+ stockLevels { stockLocationId stockOnHand stockAllocated }
32
36
  trackInventory
37
+ outOfStockThreshold
38
+ useGlobalOutOfStockThreshold
39
+ enabled
33
40
  featuredAsset { id preview }
34
41
  options { id name code }
35
- customFields
42
+ ${barcodeField ? `customFields { ${barcodeField} }` : ''}
36
43
  }
37
44
  }
38
45
  totalItems
@@ -40,11 +47,12 @@ const PRODUCT_LIST_QUERY = `
40
47
  }
41
48
  `;
42
49
 
43
- const UPDATE_PRODUCT_MUTATION = `
44
- mutation UpdateProduct($input: UpdateProductInput!) {
45
- updateProduct(input: $input) {
46
- id
47
- updatedAt
50
+ /** The high-water mark and the skew probes only compare ids and timestamps (backlog 29). */
51
+ const PRODUCT_MARK_QUERY = `
52
+ query GetProductsMark($options: ProductListOptions) {
53
+ products(options: $options) {
54
+ items { id updatedAt }
55
+ totalItems
48
56
  }
49
57
  }
50
58
  `;
@@ -52,11 +60,12 @@ const UPDATE_PRODUCT_MUTATION = `
52
60
  /**
53
61
  * Helper to execute a GraphQL query against the Vendure Admin API.
54
62
  */
55
- async function gql(
63
+ export async function gql(
56
64
  context: SyncContext,
57
65
  query: string,
58
66
  variables?: Record<string, any>,
59
67
  ): Promise<any> {
68
+ type GqlBody = { data?: any; errors?: Array<{ message?: string }> };
60
69
  const res = await fetch(`${context.baseUrl}/admin-api`, {
61
70
  method: 'POST',
62
71
  headers: {
@@ -67,82 +76,147 @@ async function gql(
67
76
  signal: context.signal,
68
77
  });
69
78
 
70
- if (!res.ok) throw new Error(`Vendure API error: ${res.status}`);
71
- return res.json();
79
+ if (!res.ok) {
80
+ const body = await res.json().catch(() => undefined) as GqlBody | undefined;
81
+ const message = body?.errors?.[0]?.message;
82
+ throw new Error(`Vendure API error: ${res.status}${message ? `: ${message}` : ''}`);
83
+ }
84
+ const body = await res.json() as GqlBody;
85
+ if (body.errors?.length) throw new Error(`Vendure GraphQL error: ${body.errors[0].message}`);
86
+ return body;
87
+ }
88
+
89
+ /**
90
+ * Probes around a pass's high-water mark for the server's updatedAt skew
91
+ * (ADR-060). `fetchTotal(after)` runs the caller's own list query (products,
92
+ * variants) and returns its totalItems. Returns the mark actually probed
93
+ * (backlog 31: `remark`, given, may re-read a newer one; see below).
94
+ */
95
+ export async function probeUpdatedAtSkew(
96
+ passHighWater: string,
97
+ updatedAtSkewMs: number,
98
+ fetchTotal: (after: string) => Promise<number>,
99
+ remark?: () => Promise<string | undefined>,
100
+ ): Promise<string> {
101
+ for (const overlap of [1, 0]) {
102
+ if (overlap === 0 && updatedAtSkewMs > 0) continue;
103
+ const after = (mark: string) => new Date(Date.parse(mark) + (overlap === 0 ? 1 : -1) - updatedAtSkewMs).toISOString();
104
+ let total = await fetchTotal(after(passHighWater));
105
+ if (overlap === 1 && total === 0 && remark) {
106
+ // The mark's own product can be deleted between the mark read and this
107
+ // probe (harmless on its own: RxDB retries the resulting error and the
108
+ // next mark read picks a survivor). Re-reading the mark once here means
109
+ // that common case settles without throwing at all.
110
+ const remarked = await remark();
111
+ if (remarked) {
112
+ passHighWater = remarked;
113
+ total = await fetchTotal(after(passHighWater));
114
+ }
115
+ }
116
+ if (overlap === 1 && total === 0) {
117
+ throw new Error('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.');
118
+ }
119
+ if (overlap === 0 && total > 0) {
120
+ console.warn('Vendure updatedAt filters over-fetch because the server is not in UTC.');
121
+ }
122
+ }
123
+ return passHighWater;
124
+ }
125
+
126
+ /** Project an API product onto the schema's top-level fields (RxDB rejects undeclared ones). Sorts `variants` by id (a stable copy; the input is not mutated), since Vendure does not guarantee variant order. */
127
+ export function toProductDocument(p: any): Record<string, unknown> {
128
+ const doc: Record<string, unknown> = { _deleted: false };
129
+ for (const field of Object.keys(vendureProductSchema.properties)) {
130
+ if (p[field] !== undefined) doc[field] = p[field];
131
+ }
132
+ if (Array.isArray(doc.variants)) {
133
+ doc.variants = [...doc.variants].sort((a: any, b: any) => compareIds(a.id, b.id));
134
+ }
135
+ return doc;
72
136
  }
73
137
 
74
138
  /**
75
139
  * Replication adapter for Vendure products.
76
140
  *
77
- * Implements pull (GraphQL query with offset pagination and updatedAt
78
- * filtering) and push (GraphQL updateProduct mutation). Designed for
79
- * use with RxDB's replicateRxCollection.
141
+ * Pull-only (GraphQL query with offset pagination and updatedAt filtering)
142
+ * for RxDB's replicateRxCollection. Catalogue data is server-owned; the
143
+ * POS never writes products.
80
144
  */
81
- export const vendureProductReplication: ReplicationAdapter<any, VendureProductCheckpoint> = {
145
+ export const createVendureProductReplication = (barcodeField?: string, updatedAtSkewMs = 0): ReplicationAdapter<any, VendureProductCheckpoint> => ({
82
146
  pull: {
83
147
  async handler(lastCheckpoint, batchSize, context) {
148
+ if (batchSize > 1000) throw new Error('Vendure Admin API take must not exceed 1000');
149
+ // A checkpoint with skip > 0 but neither passHighWater nor passTotal is
150
+ // the shape saved mid-pass by the code before #45 gave a pass its own
151
+ // high-water mark and total (that code kept only skip and updatedAt).
152
+ // Its skip counts offset pages under a different sort and a moving
153
+ // updatedAt filter, which don't line up with this pull's fixed-window
154
+ // paging by id: resuming at that offset could skip rows. Restarting the
155
+ // pass from offset 0 cannot skip anything, only re-read rows this or an
156
+ // earlier pass already applied, which RxDB's upsert makes harmless.
157
+ if (lastCheckpoint?.skip && lastCheckpoint.passHighWater === undefined && lastCheckpoint.passTotal === undefined) {
158
+ lastCheckpoint = { skip: 0, updatedAt: lastCheckpoint.updatedAt ?? '' };
159
+ }
160
+ let passHighWater = lastCheckpoint?.passHighWater ?? lastCheckpoint?.updatedAt ?? '';
161
+ if (!lastCheckpoint?.skip) {
162
+ const fetchMark = async () => {
163
+ const head = await gql(context, PRODUCT_MARK_QUERY, { options: { take: 1, sort: { updatedAt: 'DESC' } } });
164
+ return head.data?.products?.items?.[0]?.updatedAt as string | undefined;
165
+ };
166
+ const mark = await fetchMark();
167
+ passHighWater = mark ?? lastCheckpoint?.updatedAt ?? '';
168
+ // Same-ms writes after this read wait for a newer mark or periodic reconcile (ADR-060, to come).
169
+ if (mark === undefined || (lastCheckpoint?.updatedAt && passHighWater === lastCheckpoint.updatedAt)) {
170
+ return { documents: [], checkpoint: lastCheckpoint ?? { skip: 0, updatedAt: '' } };
171
+ }
172
+ passHighWater = await probeUpdatedAtSkew(passHighWater, updatedAtSkewMs, async (after) => {
173
+ const probe = await gql(context, PRODUCT_MARK_QUERY, {
174
+ options: { take: 1, sort: { updatedAt: 'DESC' }, filter: { updatedAt: { after } } },
175
+ });
176
+ return probe.data.products.totalItems;
177
+ }, fetchMark);
178
+ }
84
179
  const options: Record<string, any> = {
85
180
  take: batchSize,
86
181
  skip: lastCheckpoint?.skip ?? 0,
182
+ sort: { id: 'ASC' },
87
183
  };
88
184
 
89
185
  if (lastCheckpoint?.updatedAt) {
90
186
  options.filter = {
91
- updatedAt: { after: lastCheckpoint.updatedAt },
187
+ // Vendure after is strict; overlap by 1 ms to include timestamp ties.
188
+ updatedAt: { after: new Date(Date.parse(lastCheckpoint.updatedAt) - 1 - updatedAtSkewMs).toISOString() },
92
189
  };
93
190
  }
94
191
 
95
- const res = await gql(context, PRODUCT_LIST_QUERY, { options });
192
+ const res = await gql(context, PRODUCT_LIST_QUERY(barcodeField), { options });
96
193
 
97
- if (res.errors?.length) {
98
- throw new Error(`Vendure GraphQL error: ${res.errors[0].message}`);
194
+ let data = res.data?.products;
195
+ let passTotal = options.skip === 0 ? data.totalItems : lastCheckpoint?.passTotal ?? data.totalItems;
196
+ if (options.skip > 0 && data.totalItems < passTotal) {
197
+ options.skip = 0;
198
+ const restarted = await gql(context, PRODUCT_LIST_QUERY(barcodeField), { options });
199
+ data = restarted.data?.products;
200
+ passTotal = data.totalItems;
99
201
  }
100
-
101
- const data = res.data?.products;
102
202
  const products: any[] = data?.items ?? [];
103
- const documents = products.map((p) => ({ ...p, _deleted: false }));
104
-
105
- // Reset skip when batch is smaller than batchSize (end of page)
106
- const nextSkip = products.length < batchSize
107
- ? 0
108
- : (lastCheckpoint?.skip ?? 0) + products.length;
203
+ if (!products.length && options.skip > 0) {
204
+ return createVendureProductReplication(barcodeField, updatedAtSkewMs).pull.handler(
205
+ { skip: 0, updatedAt: lastCheckpoint?.updatedAt ?? '' }, batchSize, context,
206
+ );
207
+ }
208
+ const documents = products.map(toProductDocument);
109
209
 
110
- const checkpoint: VendureProductCheckpoint = products.length > 0
111
- ? {
112
- skip: nextSkip,
113
- updatedAt: products[products.length - 1].updatedAt,
114
- }
115
- : lastCheckpoint ?? { skip: 0, updatedAt: '' };
210
+ // Keep the lower bound fixed while paging by id; advance it only at pass end.
211
+ // RxDB merges checkpoints, so explicitly clear pass state at completion.
212
+ const checkpoint: VendureProductCheckpoint = options.skip + products.length >= data.totalItems
213
+ ? { skip: 0, updatedAt: passHighWater, passHighWater: undefined, passTotal: undefined }
214
+ : { skip: options.skip + products.length,
215
+ updatedAt: lastCheckpoint?.updatedAt ?? '', passHighWater, passTotal };
116
216
 
117
217
  return { documents, checkpoint };
118
218
  },
119
219
  },
220
+ });
120
221
 
121
- push: {
122
- async handler(changeRows, context) {
123
- const conflicts: any[] = [];
124
-
125
- for (const row of changeRows) {
126
- const doc = row.newDocumentState as any;
127
-
128
- try {
129
- const res = await gql(context, UPDATE_PRODUCT_MUTATION, {
130
- input: doc,
131
- });
132
-
133
- if (res.errors?.length) {
134
- if (row.assumedMasterState) {
135
- conflicts.push({ ...row.assumedMasterState, _deleted: false });
136
- }
137
- }
138
- } catch {
139
- if (row.assumedMasterState) {
140
- conflicts.push({ ...row.assumedMasterState, _deleted: false });
141
- }
142
- }
143
- }
144
-
145
- return conflicts;
146
- },
147
- },
148
- };
222
+ export const vendureProductReplication = createVendureProductReplication();
@@ -0,0 +1,108 @@
1
+ import type { ReplicationAdapter, SyncContext } from '@tallyui/core';
2
+ import { gql, PRODUCT_LIST_QUERY, probeUpdatedAtSkew, toProductDocument, type VendureProductCheckpoint } from './products';
3
+
4
+ /** Vendure Admin API maximum `take`; variant pages and parent re-fetch chunks use it. */
5
+ const MAX_TAKE = 1000;
6
+
7
+ const variants = (context: SyncContext, fields: string, options: Record<string, any>) => gql(context, `
8
+ query GetVariants($options: ProductVariantListOptions) {
9
+ productVariants(options: $options) { items { ${fields} } totalItems }
10
+ }
11
+ `, { options }).then((res) => res.data.productVariants as { items: any[]; totalItems: number });
12
+ /** The newest variant: each pass's high water, and a fresh install's seed. */
13
+ const newest = (context: SyncContext) => variants(context, 'id updatedAt', { take: 1, sort: { updatedAt: 'DESC' } });
14
+
15
+ /**
16
+ * Variant feed for Vendure products (ADR-060, decision 3).
17
+ *
18
+ * A variant price or stock edit bumps `ProductVariant.updatedAt` but not
19
+ * `Product.updatedAt`, so the product feed never sees it. This pull adapter
20
+ * pages changed variants with the same pass and high-water design as
21
+ * `createVendureProductReplication` and re-delivers their parent products,
22
+ * in the same document shape, into the `products` collection. It runs as a
23
+ * sub-adapter of the connector's combined `replication.products` adapter
24
+ * (`combinePullAdapters`), not as its own replication. Parents that no longer
25
+ * exist are skipped; deletions are the id reconcile's job.
26
+ *
27
+ * On a fresh install `seedCheckpoint` starts the cursor at the variant high
28
+ * water, read before the product feed's first pass, so the catalogue downloads
29
+ * once. An upgrade (a stored product checkpoint, none for this feed) is not
30
+ * seeded: its first pass re-delivers every product that has variants, once.
31
+ * That is deliberate: it heals any price or stock change the product feed
32
+ * missed before this feed existed.
33
+ *
34
+ * `variantPageSize` (the variant page `take`) is for tests and tuning.
35
+ */
36
+ export const createVendureVariantFeedReplication = (barcodeField?: string, updatedAtSkewMs = 0, variantPageSize = MAX_TAKE): ReplicationAdapter<any, VendureProductCheckpoint> => ({
37
+ pull: {
38
+ // The next call's unchanged-high-water check then ends at one read, unless
39
+ // a variant changed since. The skew probe runs when a pass starts from it.
40
+ async seedCheckpoint(context) {
41
+ const head = await newest(context);
42
+ return { skip: 0, updatedAt: head.items[0]?.updatedAt ?? '', passHighWater: undefined, passTotal: undefined };
43
+ },
44
+ async handler(lastCheckpoint, batchSize, context) {
45
+ if (batchSize > MAX_TAKE || variantPageSize > MAX_TAKE) throw new Error('Vendure Admin API take must not exceed 1000');
46
+ let passHighWater = lastCheckpoint?.passHighWater ?? lastCheckpoint?.updatedAt ?? '';
47
+ let skip = lastCheckpoint?.skip ?? 0;
48
+ let passTotal = lastCheckpoint?.passTotal;
49
+ if (!skip) {
50
+ const head = await newest(context);
51
+ passHighWater = head.items[0]?.updatedAt ?? lastCheckpoint?.updatedAt ?? '';
52
+ if (!head.items.length || (lastCheckpoint?.updatedAt && passHighWater === lastCheckpoint.updatedAt)) {
53
+ return { documents: [], checkpoint: lastCheckpoint ?? { skip: 0, updatedAt: '' } };
54
+ }
55
+ await probeUpdatedAtSkew(passHighWater, updatedAtSkewMs, async (after) => {
56
+ const probe = await variants(context, 'id updatedAt', { take: 1, sort: { updatedAt: 'DESC' }, filter: { updatedAt: { after } } });
57
+ return probe.totalItems;
58
+ });
59
+ }
60
+ const filter = lastCheckpoint?.updatedAt
61
+ // Vendure after is strict; overlap by 1 ms to include timestamp ties.
62
+ ? { updatedAt: { after: new Date(Date.parse(lastCheckpoint.updatedAt) - 1 - updatedAtSkewMs).toISOString() } }
63
+ : undefined;
64
+ const page = (from: number) => variants(context, 'id productId', { take: variantPageSize, skip: from, sort: { id: 'ASC' }, filter });
65
+
66
+ // RxDB stops pulling on a page shorter than batchSize, so keep reading
67
+ // variant pages until enough parents are delivered or the pass ends.
68
+ const seen = new Set<string>();
69
+ const documents: Record<string, unknown>[] = [];
70
+ let complete = false;
71
+ while (!complete && documents.length < batchSize) {
72
+ let data = await page(skip);
73
+ if (skip > 0 && (!data.items.length || data.totalItems < (passTotal ?? data.totalItems))) {
74
+ skip = 0; // Rows vanished mid-pass: restart; re-reads are harmless, skips are not.
75
+ data = await page(0);
76
+ }
77
+ if (skip === 0) passTotal = data.totalItems;
78
+ const parentIds = [...new Set(data.items.map((v) => String(v.productId)))].filter((id) => !seen.has(id));
79
+ parentIds.forEach((id) => seen.add(id));
80
+ if (parentIds.length) { // At most variantPageSize (<= MAX_TAKE) ids: one variant page's worth.
81
+ const res = await gql(context, PRODUCT_LIST_QUERY(barcodeField), {
82
+ options: { take: parentIds.length, filter: { id: { in: parentIds } } },
83
+ });
84
+ documents.push(...(res.data?.products?.items ?? []).map(toProductDocument));
85
+ }
86
+ skip += data.items.length;
87
+ complete = skip >= data.totalItems || !data.items.length;
88
+ }
89
+
90
+ // Keep the lower bound fixed while paging by id; advance it only at pass end.
91
+ // RxDB merges checkpoints, so explicitly clear pass state at completion.
92
+ const checkpoint: VendureProductCheckpoint = complete
93
+ ? { skip: 0, updatedAt: passHighWater, passHighWater: undefined, passTotal: undefined }
94
+ : { skip, updatedAt: lastCheckpoint?.updatedAt ?? '', passHighWater, passTotal };
95
+ // RxDB saves no checkpoint from an empty page. If this call's variant
96
+ // pages mapped to no live parents but the cursor moved, the cursor would
97
+ // stick mid-pass and a later change below it would be lost. Carry the
98
+ // checkpoint on one current product; re-delivering it is harmless.
99
+ const moved = (['skip', 'updatedAt', 'passHighWater', 'passTotal'] as const)
100
+ .some((key) => checkpoint[key] !== lastCheckpoint?.[key]);
101
+ if (!documents.length && moved) {
102
+ const res = await gql(context, PRODUCT_LIST_QUERY(barcodeField), { options: { take: 1 } });
103
+ documents.push(...(res.data?.products?.items ?? []).map(toProductDocument));
104
+ }
105
+ return { documents, checkpoint };
106
+ },
107
+ },
108
+ });
@@ -12,7 +12,9 @@ import type { RxJsonSchema } from 'rxdb';
12
12
  * - No native barcode field (custom fields only)
13
13
  */
14
14
  export const vendureProductSchema: RxJsonSchema<any> = {
15
- version: 0,
15
+ // 1: declares variant trackInventory/outOfStockThreshold/useGlobalOutOfStockThreshold/enabled
16
+ // (backlog 28). A bump drops and resyncs (ConnectorSchemas, ADR-060 amendment 9).
17
+ version: 1,
16
18
  primaryKey: 'id',
17
19
  type: 'object',
18
20
  properties: {
@@ -80,7 +82,21 @@ export const vendureProductSchema: RxJsonSchema<any> = {
80
82
  currencyCode: { type: 'string' },
81
83
  stockLevel: { type: 'string' },
82
84
  stockOnHand: { type: 'number' },
85
+ stockLevels: {
86
+ type: 'array',
87
+ items: {
88
+ type: 'object',
89
+ properties: {
90
+ stockLocationId: { type: 'string' },
91
+ stockOnHand: { type: 'number' },
92
+ stockAllocated: { type: 'number' },
93
+ },
94
+ },
95
+ },
83
96
  trackInventory: { type: 'string' },
97
+ outOfStockThreshold: { type: 'number' },
98
+ useGlobalOutOfStockThreshold: { type: 'boolean' },
99
+ enabled: { type: 'boolean' },
84
100
  featuredAsset: {
85
101
  type: ['object', 'null'],
86
102
  properties: {
@@ -0,0 +1,78 @@
1
+ import { StoreSettingsError } from '@tallyui/core';
2
+ import type { StoreSettings, StoreSettingsChoice, SyncContext } from '@tallyui/core';
3
+
4
+ import { gql } from './replication/products';
5
+
6
+ const CHANNEL_AND_CATEGORIES_QUERY = `
7
+ query StoreSettingsChannel {
8
+ activeChannel { defaultCurrencyCode pricesIncludeTax defaultTaxZone { id } }
9
+ taxCategories { items { id isDefault } }
10
+ }
11
+ `;
12
+
13
+ const TAX_RATES_QUERY = `
14
+ query StoreSettingsTaxRates($zoneId: String!) {
15
+ taxRates(options: { take: 1000, filter: { zoneId: { eq: $zoneId } } }) {
16
+ items { enabled value category { id } customerGroup { id } }
17
+ }
18
+ }
19
+ `;
20
+
21
+ type ActiveChannel = { defaultCurrencyCode: string; pricesIncludeTax: boolean; defaultTaxZone: { id: string } | null };
22
+ type TaxCategory = { id: string; isDefault: boolean };
23
+ type TaxRateItem = { enabled: boolean; value: number; category: { id: string } | null; customerGroup: { id: string } | null };
24
+
25
+ /** `gql` throws a plain `Error` for a non-OK response or a GraphQL `errors` body; both become `StoreSettingsError('failed')` here. */
26
+ async function query(context: SyncContext, source: string, variables?: Record<string, unknown>): Promise<any> {
27
+ try {
28
+ return await gql(context, source, variables);
29
+ } catch (error) {
30
+ throw new StoreSettingsError('failed', error instanceof Error ? error.message : String(error));
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Reads the active channel's currency and tax-inclusivity (ADR-049), and the
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`:
38
+ * Vendure's channel is already chosen by the `vendure-token` header sent
39
+ * with every request, so there is no `pricingContext` either.
40
+ */
41
+ export const vendureStoreSettings = async (context: SyncContext, _choice?: StoreSettingsChoice): Promise<StoreSettings> => {
42
+ const head = await query(context, CHANNEL_AND_CATEGORIES_QUERY);
43
+ const channel = head.data?.activeChannel as ActiveChannel | undefined;
44
+ if (!channel) throw new StoreSettingsError('failed', 'Vendure returned no active channel');
45
+ const categories: TaxCategory[] = head.data?.taxCategories?.items ?? [];
46
+
47
+ let rateItems: TaxRateItem[] = [];
48
+ if (channel.defaultTaxZone) {
49
+ const ratesBody = await query(context, TAX_RATES_QUERY, { zoneId: channel.defaultTaxZone.id });
50
+ rateItems = ratesBody.data?.taxRates?.items ?? [];
51
+ }
52
+
53
+ const byCategory = new Map<string, number>();
54
+ for (const rate of rateItems) {
55
+ if (!rate.enabled || rate.customerGroup || !rate.category) continue;
56
+ // Rounded once, here, at the connector's edge (a backend decimal percentage -> integer ppm).
57
+ byCategory.set(rate.category.id, Math.round(rate.value * 10_000));
58
+ }
59
+
60
+ // Vendure's own fallback for a variant created without a category
61
+ // (getTaxCategoryForNewVariant, @vendure/core 3.7.3,
62
+ // dist/service/services/product-variant.service.js:875): the isDefault
63
+ // category, or, when none is flagged, taxCategories.items[0]. When that
64
+ // category has no enabled rate in the zone (or there are no categories
65
+ // at all), `default` is 0 — that is what Vendure itself charges in that
66
+ // case, never a silent guess.
67
+ const defaultCategory = categories.find((c) => c.isDefault) ?? categories[0];
68
+ const taxRatesPpm: { default: number; [taxClass: string]: number } = {
69
+ default: defaultCategory ? byCategory.get(defaultCategory.id) ?? 0 : 0,
70
+ };
71
+ for (const [categoryId, ppm] of byCategory) taxRatesPpm[categoryId] = ppm;
72
+
73
+ return {
74
+ currency: channel.defaultCurrencyCode,
75
+ pricesIncludeTax: channel.pricesIncludeTax,
76
+ taxRatesPpm,
77
+ };
78
+ };
@@ -15,7 +15,7 @@ const PRODUCT_LIST_QUERY = `
15
15
  }
16
16
  `;
17
17
 
18
- const PRODUCT_DETAIL_QUERY = `
18
+ const PRODUCT_DETAIL_QUERY = (barcodeField?: string) => `
19
19
  query GetProduct($id: ID!) {
20
20
  product(id: $id) {
21
21
  id
@@ -36,12 +36,11 @@ const PRODUCT_DETAIL_QUERY = `
36
36
  price
37
37
  priceWithTax
38
38
  currencyCode
39
- stockLevel
40
- stockOnHand
39
+ stockLevels { stockLocationId stockOnHand stockAllocated }
41
40
  trackInventory
42
41
  featuredAsset { id preview }
43
42
  options { id name code }
44
- customFields
43
+ ${barcodeField ? `customFields { ${barcodeField} }` : ''}
45
44
  }
46
45
  }
47
46
  }
@@ -53,7 +52,7 @@ const PRODUCT_DETAIL_QUERY = `
53
52
  * Uses the Admin GraphQL API. Vendure uses offset-based pagination
54
53
  * with `take` and `skip` options.
55
54
  */
56
- export const vendureProductSync: CollectionSync = {
55
+ export const createVendureProductSync = (barcodeField?: string): CollectionSync => ({
57
56
  fetchAllIds: async (context: SyncContext) => {
58
57
  const entries: { id: string; dateModified?: string }[] = [];
59
58
  const take = 100;
@@ -86,7 +85,7 @@ export const vendureProductSync: CollectionSync = {
86
85
  const products: any[] = [];
87
86
  // Vendure doesn't support batch-by-IDs natively, so fetch individually
88
87
  for (const id of ids) {
89
- const res = await gql(context, PRODUCT_DETAIL_QUERY, { id });
88
+ const res = await gql(context, PRODUCT_DETAIL_QUERY(barcodeField), { id });
90
89
  if (res.data?.product) {
91
90
  products.push(res.data.product);
92
91
  }
@@ -118,7 +117,7 @@ export const vendureProductSync: CollectionSync = {
118
117
 
119
118
  // Fetch full details for each modified product
120
119
  for (const item of data.items ?? []) {
121
- const detail = await gql(context, PRODUCT_DETAIL_QUERY, { id: item.id });
120
+ const detail = await gql(context, PRODUCT_DETAIL_QUERY(barcodeField), { id: item.id });
122
121
  if (detail.data?.product) {
123
122
  products.push(detail.data.product);
124
123
  }
@@ -129,7 +128,9 @@ export const vendureProductSync: CollectionSync = {
129
128
 
130
129
  return products;
131
130
  },
132
- };
131
+ });
132
+
133
+ export const vendureProductSync = createVendureProductSync();
133
134
 
134
135
  /**
135
136
  * Helper to execute a GraphQL query against the Vendure Admin API.