@porulle/plugin-channel-connector 0.67.0 → 0.68.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/dist/index.js +8 -4
- package/dist/live-credentials.d.ts +6 -1
- package/dist/live-credentials.js +13 -4
- package/dist/service.d.ts +10 -3
- package/dist/service.js +19 -2
- package/package.json +2 -2
- package/src/index.ts +7 -3
- package/src/live-credentials.ts +11 -4
- package/src/service.ts +25 -5
package/dist/index.js
CHANGED
|
@@ -361,13 +361,15 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
361
361
|
const userId = actor?.userId;
|
|
362
362
|
if (!userId)
|
|
363
363
|
return oauthError(403, "USER_REQUIRED", "Connecting a store needs a signed-in user.");
|
|
364
|
-
const claims = await service.connectClaims({ orgId, actor, raw });
|
|
365
|
-
if (!claims.ok)
|
|
366
|
-
return connectOutcome(oauth.postConnectRedirect, { error: claims.code ?? "CONNECT_REFUSED", message: claims.error });
|
|
367
364
|
const typed = String(query.shop ?? query.store ?? "");
|
|
368
365
|
const storeDomain = connector.normalizeStoreDomain ? connector.normalizeStoreDomain(typed) : typed;
|
|
369
366
|
if (!storeDomain)
|
|
370
367
|
return connectOutcome(oauth.postConnectRedirect, { error: "INVALID_STORE_DOMAIN", message: `"${typed}" does not name a ${provider} store.` });
|
|
368
|
+
// Refused HERE, before the merchant reaches the provider: a grant issued for a shop retires
|
|
369
|
+
// that shop's other grants, so a refusal at the callback would already have broken them.
|
|
370
|
+
const claims = await service.connectClaims({ orgId, actor, raw, storeDomain });
|
|
371
|
+
if (!claims.ok)
|
|
372
|
+
return connectOutcome(oauth.postConnectRedirect, { error: claims.code ?? "CONNECT_REFUSED", message: claims.error });
|
|
371
373
|
const state = signState({
|
|
372
374
|
provider,
|
|
373
375
|
orgId,
|
|
@@ -514,7 +516,9 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
514
516
|
webhookSecret: z.string().min(1).optional(),
|
|
515
517
|
}))
|
|
516
518
|
.handler(async ({ input, orgId, actor, raw }) => {
|
|
517
|
-
const
|
|
519
|
+
const requested = input;
|
|
520
|
+
const normalize = service.getConnector(requested.provider)?.normalizeStoreDomain;
|
|
521
|
+
const claims = unwrap(await service.connectClaims({ orgId, actor, raw, storeDomain: (normalize ? normalize(requested.storeDomain) : undefined) ?? requested.storeDomain }));
|
|
518
522
|
return unwrap(await service.connectStore(orgId, input, { orgId, userId: actor?.userId ?? null, claims, raw }));
|
|
519
523
|
});
|
|
520
524
|
channels.get("/stores")
|
|
@@ -15,10 +15,15 @@ import type { ChannelConnector, ChannelConnectorError, ChannelStore, PluginDb, R
|
|
|
15
15
|
* token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
|
|
16
16
|
* later call with a credential error nobody is shown.
|
|
17
17
|
*/
|
|
18
|
-
export declare function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore
|
|
18
|
+
export declare function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore, options?: {
|
|
19
|
+
force?: boolean;
|
|
20
|
+
}): Promise<Result<ChannelStore, ChannelConnectorError>>;
|
|
19
21
|
/**
|
|
20
22
|
* The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
|
|
21
23
|
* call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
|
|
22
24
|
* credentials never expire.
|
|
25
|
+
*
|
|
26
|
+
* A call the provider answers with {@link CHANNEL_CREDENTIALS_REJECTED} — a token retired before its
|
|
27
|
+
* stated expiry — is retried ONCE on credentials refreshed by force. A second rejection is the answer.
|
|
23
28
|
*/
|
|
24
29
|
export declare function withLiveCredentials(connector: ChannelConnector, db: PluginDb): ChannelConnector;
|
package/dist/live-credentials.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Err, Ok } from "@porulle/core";
|
|
1
|
+
import { CHANNEL_CREDENTIALS_REJECTED, Err, Ok } from "@porulle/core";
|
|
2
2
|
import { and, eq, sql } from "@porulle/core/drizzle";
|
|
3
3
|
import { connectedStores } from "./schema.js";
|
|
4
4
|
/**
|
|
@@ -17,10 +17,10 @@ import { connectedStores } from "./schema.js";
|
|
|
17
17
|
* token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
|
|
18
18
|
* later call with a credential error nobody is shown.
|
|
19
19
|
*/
|
|
20
|
-
export async function resolveLiveCredentials(connector, db, store) {
|
|
20
|
+
export async function resolveLiveCredentials(connector, db, store, options = {}) {
|
|
21
21
|
if (!connector.liveCredentials)
|
|
22
22
|
return Ok(store);
|
|
23
|
-
const answer = await connector.liveCredentials(store);
|
|
23
|
+
const answer = await connector.liveCredentials(store, options);
|
|
24
24
|
if (!answer.ok) {
|
|
25
25
|
if (answer.error.retriable !== true) {
|
|
26
26
|
await db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
|
|
@@ -44,6 +44,9 @@ export async function resolveLiveCredentials(connector, db, store) {
|
|
|
44
44
|
* The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
|
|
45
45
|
* call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
|
|
46
46
|
* credentials never expire.
|
|
47
|
+
*
|
|
48
|
+
* A call the provider answers with {@link CHANNEL_CREDENTIALS_REJECTED} — a token retired before its
|
|
49
|
+
* stated expiry — is retried ONCE on credentials refreshed by force. A second rejection is the answer.
|
|
47
50
|
*/
|
|
48
51
|
export function withLiveCredentials(connector, db) {
|
|
49
52
|
if (!connector.liveCredentials)
|
|
@@ -52,7 +55,13 @@ export function withLiveCredentials(connector, db) {
|
|
|
52
55
|
const current = await resolveLiveCredentials(connector, db, store);
|
|
53
56
|
if (!current.ok)
|
|
54
57
|
return current;
|
|
55
|
-
|
|
58
|
+
const first = await call.call(connector, current.value, ...args);
|
|
59
|
+
if (first.ok || first.error.code !== CHANNEL_CREDENTIALS_REJECTED)
|
|
60
|
+
return first;
|
|
61
|
+
const refreshed = await resolveLiveCredentials(connector, db, current.value, { force: true });
|
|
62
|
+
if (!refreshed.ok)
|
|
63
|
+
return refreshed;
|
|
64
|
+
return call.call(connector, refreshed.value, ...args);
|
|
56
65
|
};
|
|
57
66
|
return {
|
|
58
67
|
...connector,
|
package/dist/service.d.ts
CHANGED
|
@@ -120,9 +120,14 @@ export interface StoreConnectActor {
|
|
|
120
120
|
}
|
|
121
121
|
/**
|
|
122
122
|
* Resolves, from the request that STARTS a connection, the facts the consumer needs to bind the store
|
|
123
|
-
* later. Throw to refuse the start
|
|
123
|
+
* later. Throw to refuse the start — before the merchant is sent to the provider, which matters: a
|
|
124
|
+
* provider such as Shopify retires a shop's other grants the moment a new one is issued, so a
|
|
125
|
+
* connection refused only at the callback has already broken the shop's existing one. `storeDomain` is
|
|
126
|
+
* the store being connected, canonicalised. Runs for OAuth start and for `POST /stores`.
|
|
124
127
|
*/
|
|
125
|
-
export type ConnectClaims = (context: StoreReadContext
|
|
128
|
+
export type ConnectClaims = (context: StoreReadContext & {
|
|
129
|
+
storeDomain: string;
|
|
130
|
+
}) => Promise<Record<string, string>> | Record<string, string>;
|
|
126
131
|
/**
|
|
127
132
|
* Binds a just-connected store to whatever the consumer means by an owner, INSIDE the transaction
|
|
128
133
|
* that wrote the store row — so a store and its binding commit together or not at all. Throw to
|
|
@@ -510,7 +515,9 @@ export declare class ChannelConnectorService {
|
|
|
510
515
|
/** The store with credentials good for a call the host makes itself, e.g. its own Admin API write. */
|
|
511
516
|
liveStore(orgId: string, storeId: string): Promise<PluginResult<ChannelStore>>;
|
|
512
517
|
/** The consumer's claims for a connection starting from this request. See {@link ConnectClaims}. */
|
|
513
|
-
connectClaims(context: StoreReadContext
|
|
518
|
+
connectClaims(context: StoreReadContext & {
|
|
519
|
+
storeDomain: string;
|
|
520
|
+
}): Promise<PluginResult<Record<string, string>>>;
|
|
514
521
|
/** The caller's allow-list, or null for unconfined. See {@link ConfineStores}. */
|
|
515
522
|
private allowedStores;
|
|
516
523
|
/** NOT_FOUND for a store outside the caller's set, exactly as for one that does not exist. */
|
package/dist/service.js
CHANGED
|
@@ -1299,6 +1299,16 @@ export class ChannelConnectorService {
|
|
|
1299
1299
|
continue;
|
|
1300
1300
|
}
|
|
1301
1301
|
variantIds.set(sourceVariant.externalId, variantId);
|
|
1302
|
+
// The provider's per-variant facts (the inventory item a stock webhook names, the weight shipping
|
|
1303
|
+
// prices by) merge into the variant per key, as entity metadata does: the source's keys overwrite
|
|
1304
|
+
// their own and nothing else. The import fast path writes them at creation; without this the
|
|
1305
|
+
// editor path — every reconcile — left them off, and a stock webhook could not find its variant.
|
|
1306
|
+
const sourceMetadata = sourceVariant.metadata ?? {};
|
|
1307
|
+
if (Object.keys(sourceMetadata).length > 0) {
|
|
1308
|
+
await this.db.update(variants)
|
|
1309
|
+
.set({ metadata: sql `coalesce(${variants.metadata}, '{}'::jsonb) || ${JSON.stringify(sourceMetadata)}::jsonb` })
|
|
1310
|
+
.where(and(eq(variants.id, variantId), sql `not (coalesce(${variants.metadata}, '{}'::jsonb) @> ${JSON.stringify(sourceMetadata)}::jsonb)`));
|
|
1311
|
+
}
|
|
1302
1312
|
if (applyOptionValues) {
|
|
1303
1313
|
const desiredOptionValueIds = Object.entries(sourceVariant.optionValues ?? {})
|
|
1304
1314
|
.map(([name, value]) => optionValueIds.get(name)?.get(value))
|
|
@@ -3476,14 +3486,21 @@ export class ChannelConnectorService {
|
|
|
3476
3486
|
archived += 1;
|
|
3477
3487
|
}
|
|
3478
3488
|
}
|
|
3479
|
-
|
|
3489
|
+
// Stock is levelled against the mappings as they stand AFTER convergence. Read before it, as the
|
|
3490
|
+
// archive plan above must be, a first import walked an empty list and left every product it had
|
|
3491
|
+
// just created with no inventory level at all. Re-read only when convergence wrote something: an
|
|
3492
|
+
// unchanged reconcile creates no mapping and keeps its statement budget.
|
|
3493
|
+
const levelled = converged.value.imported + converged.value.converged > 0
|
|
3494
|
+
? await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId)))
|
|
3495
|
+
: mappings;
|
|
3496
|
+
const inventory = await connector.fetchInventory(store, levelled.map((mapping) => mapping.externalId));
|
|
3480
3497
|
if (!inventory.ok)
|
|
3481
3498
|
return PluginErr(inventory.error.message);
|
|
3482
3499
|
const existingLevels = await this.db.select().from(inventoryLevels).where(eq(inventoryLevels.organizationId, orgId));
|
|
3483
3500
|
const inventoryService = this.services.inventory;
|
|
3484
3501
|
let inventoryUpdated = 0;
|
|
3485
3502
|
for (const level of inventory.value) {
|
|
3486
|
-
const mapping =
|
|
3503
|
+
const mapping = levelled.find((entry) => entry.externalId === level.externalId);
|
|
3487
3504
|
if (!mapping)
|
|
3488
3505
|
continue;
|
|
3489
3506
|
const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/plugin-channel-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.68.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@hono/zod-openapi": "^1.2.2",
|
|
24
24
|
"hono": "^4.12.5",
|
|
25
|
-
"@porulle/core": "0.
|
|
25
|
+
"@porulle/core": "0.68.1"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^24.5.2",
|
package/src/index.ts
CHANGED
|
@@ -532,11 +532,13 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
532
532
|
// here, carried in the signed state. A caller with no user (an API key) cannot start one.
|
|
533
533
|
const userId = actor?.userId;
|
|
534
534
|
if (!userId) return oauthError(403, "USER_REQUIRED", "Connecting a store needs a signed-in user.");
|
|
535
|
-
const claims = await service.connectClaims({ orgId, actor, raw });
|
|
536
|
-
if (!claims.ok) return connectOutcome(oauth.postConnectRedirect, { error: claims.code ?? "CONNECT_REFUSED", message: claims.error });
|
|
537
535
|
const typed = String((query as { shop?: string; store?: string }).shop ?? (query as { store?: string }).store ?? "");
|
|
538
536
|
const storeDomain = connector.normalizeStoreDomain ? connector.normalizeStoreDomain(typed) : typed;
|
|
539
537
|
if (!storeDomain) return connectOutcome(oauth.postConnectRedirect, { error: "INVALID_STORE_DOMAIN", message: `"${typed}" does not name a ${provider} store.` });
|
|
538
|
+
// Refused HERE, before the merchant reaches the provider: a grant issued for a shop retires
|
|
539
|
+
// that shop's other grants, so a refusal at the callback would already have broken them.
|
|
540
|
+
const claims = await service.connectClaims({ orgId, actor, raw, storeDomain });
|
|
541
|
+
if (!claims.ok) return connectOutcome(oauth.postConnectRedirect, { error: claims.code ?? "CONNECT_REFUSED", message: claims.error });
|
|
540
542
|
const state = signState({
|
|
541
543
|
provider,
|
|
542
544
|
orgId,
|
|
@@ -669,7 +671,9 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
669
671
|
webhookSecret: z.string().min(1).optional(),
|
|
670
672
|
}))
|
|
671
673
|
.handler(async ({ input, orgId, actor, raw }: ChannelRouteContext) => {
|
|
672
|
-
const
|
|
674
|
+
const requested = input as { provider: string; storeDomain: string };
|
|
675
|
+
const normalize = service.getConnector(requested.provider)?.normalizeStoreDomain;
|
|
676
|
+
const claims = unwrap(await service.connectClaims({ orgId, actor, raw, storeDomain: (normalize ? normalize(requested.storeDomain) : undefined) ?? requested.storeDomain }));
|
|
673
677
|
return unwrap(await service.connectStore(
|
|
674
678
|
orgId,
|
|
675
679
|
input as {
|
package/src/live-credentials.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Err, Ok } from "@porulle/core";
|
|
1
|
+
import { CHANNEL_CREDENTIALS_REJECTED, Err, Ok } from "@porulle/core";
|
|
2
2
|
import type { ChannelConnector, ChannelConnectorError, ChannelStore, PluginDb, Result } from "@porulle/core";
|
|
3
3
|
import { and, eq, sql } from "@porulle/core/drizzle";
|
|
4
4
|
import { connectedStores } from "./schema.js";
|
|
@@ -19,9 +19,9 @@ import { connectedStores } from "./schema.js";
|
|
|
19
19
|
* token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
|
|
20
20
|
* later call with a credential error nobody is shown.
|
|
21
21
|
*/
|
|
22
|
-
export async function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore): Promise<Result<ChannelStore, ChannelConnectorError>> {
|
|
22
|
+
export async function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore, options: { force?: boolean } = {}): Promise<Result<ChannelStore, ChannelConnectorError>> {
|
|
23
23
|
if (!connector.liveCredentials) return Ok(store);
|
|
24
|
-
const answer = await connector.liveCredentials(store);
|
|
24
|
+
const answer = await connector.liveCredentials(store, options);
|
|
25
25
|
if (!answer.ok) {
|
|
26
26
|
if (answer.error.retriable !== true) {
|
|
27
27
|
await db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
|
|
@@ -45,13 +45,20 @@ type StoreCall<A extends unknown[], T> = (store: ChannelStore, ...args: A) => Pr
|
|
|
45
45
|
* The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
|
|
46
46
|
* call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
|
|
47
47
|
* credentials never expire.
|
|
48
|
+
*
|
|
49
|
+
* A call the provider answers with {@link CHANNEL_CREDENTIALS_REJECTED} — a token retired before its
|
|
50
|
+
* stated expiry — is retried ONCE on credentials refreshed by force. A second rejection is the answer.
|
|
48
51
|
*/
|
|
49
52
|
export function withLiveCredentials(connector: ChannelConnector, db: PluginDb): ChannelConnector {
|
|
50
53
|
if (!connector.liveCredentials) return connector;
|
|
51
54
|
const around = <A extends unknown[], T>(call: StoreCall<A, T>): StoreCall<A, T> => async (store, ...args) => {
|
|
52
55
|
const current = await resolveLiveCredentials(connector, db, store);
|
|
53
56
|
if (!current.ok) return current;
|
|
54
|
-
|
|
57
|
+
const first = await call.call(connector, current.value, ...args);
|
|
58
|
+
if (first.ok || first.error.code !== CHANNEL_CREDENTIALS_REJECTED) return first;
|
|
59
|
+
const refreshed = await resolveLiveCredentials(connector, db, current.value, { force: true });
|
|
60
|
+
if (!refreshed.ok) return refreshed;
|
|
61
|
+
return call.call(connector, refreshed.value, ...args);
|
|
55
62
|
};
|
|
56
63
|
return {
|
|
57
64
|
...connector,
|
package/src/service.ts
CHANGED
|
@@ -266,9 +266,12 @@ export interface StoreConnectActor {
|
|
|
266
266
|
|
|
267
267
|
/**
|
|
268
268
|
* Resolves, from the request that STARTS a connection, the facts the consumer needs to bind the store
|
|
269
|
-
* later. Throw to refuse the start
|
|
269
|
+
* later. Throw to refuse the start — before the merchant is sent to the provider, which matters: a
|
|
270
|
+
* provider such as Shopify retires a shop's other grants the moment a new one is issued, so a
|
|
271
|
+
* connection refused only at the callback has already broken the shop's existing one. `storeDomain` is
|
|
272
|
+
* the store being connected, canonicalised. Runs for OAuth start and for `POST /stores`.
|
|
270
273
|
*/
|
|
271
|
-
export type ConnectClaims = (context: StoreReadContext) => Promise<Record<string, string>> | Record<string, string>;
|
|
274
|
+
export type ConnectClaims = (context: StoreReadContext & { storeDomain: string }) => Promise<Record<string, string>> | Record<string, string>;
|
|
272
275
|
|
|
273
276
|
/**
|
|
274
277
|
* Binds a just-connected store to whatever the consumer means by an owner, INSIDE the transaction
|
|
@@ -2057,6 +2060,16 @@ export class ChannelConnectorService {
|
|
|
2057
2060
|
continue;
|
|
2058
2061
|
}
|
|
2059
2062
|
variantIds.set(sourceVariant.externalId, variantId);
|
|
2063
|
+
// The provider's per-variant facts (the inventory item a stock webhook names, the weight shipping
|
|
2064
|
+
// prices by) merge into the variant per key, as entity metadata does: the source's keys overwrite
|
|
2065
|
+
// their own and nothing else. The import fast path writes them at creation; without this the
|
|
2066
|
+
// editor path — every reconcile — left them off, and a stock webhook could not find its variant.
|
|
2067
|
+
const sourceMetadata = sourceVariant.metadata ?? {};
|
|
2068
|
+
if (Object.keys(sourceMetadata).length > 0) {
|
|
2069
|
+
await this.db.update(variants)
|
|
2070
|
+
.set({ metadata: sql`coalesce(${variants.metadata}, '{}'::jsonb) || ${JSON.stringify(sourceMetadata)}::jsonb` })
|
|
2071
|
+
.where(and(eq(variants.id, variantId), sql`not (coalesce(${variants.metadata}, '{}'::jsonb) @> ${JSON.stringify(sourceMetadata)}::jsonb)`));
|
|
2072
|
+
}
|
|
2060
2073
|
if (applyOptionValues) {
|
|
2061
2074
|
const desiredOptionValueIds = Object.entries(sourceVariant.optionValues ?? {})
|
|
2062
2075
|
.map(([name, value]) => optionValueIds.get(name)?.get(value))
|
|
@@ -3198,7 +3211,7 @@ export class ChannelConnectorService {
|
|
|
3198
3211
|
}
|
|
3199
3212
|
|
|
3200
3213
|
/** The consumer's claims for a connection starting from this request. See {@link ConnectClaims}. */
|
|
3201
|
-
async connectClaims(context: StoreReadContext): Promise<PluginResult<Record<string, string>>> {
|
|
3214
|
+
async connectClaims(context: StoreReadContext & { storeDomain: string }): Promise<PluginResult<Record<string, string>>> {
|
|
3202
3215
|
if (!this.options.connectClaims) return Ok({});
|
|
3203
3216
|
try {
|
|
3204
3217
|
return Ok(await this.options.connectClaims(context));
|
|
@@ -4546,7 +4559,14 @@ export class ChannelConnectorService {
|
|
|
4546
4559
|
}
|
|
4547
4560
|
}
|
|
4548
4561
|
|
|
4549
|
-
|
|
4562
|
+
// Stock is levelled against the mappings as they stand AFTER convergence. Read before it, as the
|
|
4563
|
+
// archive plan above must be, a first import walked an empty list and left every product it had
|
|
4564
|
+
// just created with no inventory level at all. Re-read only when convergence wrote something: an
|
|
4565
|
+
// unchanged reconcile creates no mapping and keeps its statement budget.
|
|
4566
|
+
const levelled = converged.value.imported + converged.value.converged > 0
|
|
4567
|
+
? await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId)))
|
|
4568
|
+
: mappings;
|
|
4569
|
+
const inventory = await connector.fetchInventory(store as ChannelStore, levelled.map((mapping) => mapping.externalId));
|
|
4550
4570
|
if (!inventory.ok) return PluginErr(inventory.error.message);
|
|
4551
4571
|
const existingLevels = await this.db.select().from(inventoryLevels).where(eq(inventoryLevels.organizationId, orgId));
|
|
4552
4572
|
const inventoryService = this.services.inventory as {
|
|
@@ -4554,7 +4574,7 @@ export class ChannelConnectorService {
|
|
|
4554
4574
|
};
|
|
4555
4575
|
let inventoryUpdated = 0;
|
|
4556
4576
|
for (const level of inventory.value) {
|
|
4557
|
-
const mapping =
|
|
4577
|
+
const mapping = levelled.find((entry) => entry.externalId === level.externalId);
|
|
4558
4578
|
if (!mapping) continue;
|
|
4559
4579
|
const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
|
|
4560
4580
|
// Stock cannot sit below zero here, so negative remote stock compares as the zero it is stored as.
|