@porulle/plugin-channel-connector 0.65.0 → 0.66.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/service.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Actor, ChannelCatalogItem, ChannelConnector, ChannelOrderSlice, ChannelPushCatalogField, ChannelPushCatalogImage, ChannelPushCatalogItem, ChannelPushCatalogItemOutcome, ChannelPushCatalogResult, PluginDb, PluginResult, PluginTxFn } from "@porulle/core";
1
+ import { z } from "zod";
2
+ import type { Actor, ChannelCatalogItem, ChannelConnector, ChannelOrderSlice, ChannelPushCatalogField, ChannelPushCatalogImage, ChannelPushCatalogItem, ChannelPushCatalogItemOutcome, ChannelPushCatalogResult, ChannelStore, PluginDb, PluginResult, PluginTxFn } from "@porulle/core";
2
3
  import type { ChannelCatalogImage } from "@porulle/core";
3
4
  import type { FieldOwner, FieldPath } from "@porulle/core";
4
5
  import type { JobsAdapter } from "@porulle/core";
@@ -91,18 +92,49 @@ export interface ChannelComplianceData {
91
92
  }>;
92
93
  }
93
94
  /**
94
- * What a consumer is allowed to read, resolved per request.
95
+ * Which stores the caller may see and act on, resolved per request.
95
96
  *
96
97
  * Returning `null` means "do not confine" and is the default — every existing consumer keeps the
97
- * organization-wide behaviour. An array is the complete set of store ids this caller may see, and
98
- * **`[]` means none**, not "no filter".
98
+ * organization-wide behaviour. An array is the complete set of store ids this caller may reach, and
99
+ * **`[]` means none**, not "no filter". Applied to the list AND to every route that names one store,
100
+ * where a store outside the set answers NOT_FOUND — a refusal must not confirm the store exists.
99
101
  *
100
102
  * It takes IDS rather than a tenant, deliberately. `vendor`, `seller`, `team` are models a consumer
101
103
  * owns; this package is generic commerce and acquiring one of them here would push a marketplace
102
104
  * concept into every deployment that has no such thing. The consumer resolves the meaning and hands
103
105
  * back the answer.
104
106
  */
105
- export type ConfineStoreReads = (context: StoreReadContext) => Promise<readonly string[] | null> | readonly string[] | null;
107
+ export type ConfineStores = (context: StoreReadContext) => Promise<readonly string[] | null> | readonly string[] | null;
108
+ /** Who connected a store: the signed-in user who started OAuth, or the caller of `POST /stores`. */
109
+ export interface StoreConnectActor {
110
+ orgId: string;
111
+ userId: string | null;
112
+ /** Core's request escape hatch when the connect is a request; absent on the OAuth callback. */
113
+ raw?: unknown;
114
+ }
115
+ /**
116
+ * Binds a just-connected store to whatever the consumer means by an owner, INSIDE the transaction
117
+ * that wrote the store row — so a store and its binding commit together or not at all. Throw to
118
+ * refuse the connection; the store row is rolled back with it.
119
+ */
120
+ export type BindConnectedStore = (input: {
121
+ db: PluginDb;
122
+ store: ConnectedStore;
123
+ actor: StoreConnectActor;
124
+ }) => Promise<void>;
125
+ /** Work that follows a committed connection: the first import, provider-attested facts, keys. */
126
+ export type AfterStoreConnected = (input: {
127
+ store: ConnectedStore;
128
+ actor: StoreConnectActor;
129
+ connector: ChannelConnector;
130
+ }) => Promise<void>;
131
+ /** Entities a provider webhook just created or changed, converged; the host projects them. */
132
+ export type OnStoreCatalogChanged = (input: {
133
+ orgId: string;
134
+ storeId: string;
135
+ entityIds: string[];
136
+ convergence: CatalogPageConvergence;
137
+ }) => Promise<void>;
106
138
  /** What a consumer needs to resolve the caller. `raw` is core's documented request escape hatch. */
107
139
  export interface StoreReadContext {
108
140
  orgId: string;
@@ -115,13 +147,28 @@ export interface StoreReadContext {
115
147
  export interface ChannelConnectorPluginOptions {
116
148
  connectors?: ChannelConnector[];
117
149
  /**
118
- * Confines store reads to a set the consumer chooses. See {@link ConfineStoreReads}.
150
+ * Confines stores to a set the consumer chooses. See {@link ConfineStores}.
119
151
  *
120
152
  * Absent by default, because narrowing an existing read for every deployment would be a breaking
121
153
  * change to a published package. A consumer that needs confinement opts in; one that does not is
122
154
  * unaffected.
123
155
  */
124
- confineStoreReads?: ConfineStoreReads;
156
+ confineStores?: ConfineStores;
157
+ /** See {@link BindConnectedStore}. */
158
+ bindConnectedStore?: BindConnectedStore;
159
+ /** See {@link AfterStoreConnected}. */
160
+ afterStoreConnected?: AfterStoreConnected;
161
+ /** See {@link OnStoreCatalogChanged}. Absent, a webhook's products converge and nothing else runs. */
162
+ onStoreCatalogChanged?: OnStoreCatalogChanged;
163
+ /**
164
+ * This deployment's public origin. Required for a connector that registers webhooks per store: a
165
+ * provider delivers to an ABSOLUTE address, and a relative one is refused at connect.
166
+ */
167
+ publicUrl?: string;
168
+ /**
169
+ * `postConnectRedirect` is where the merchant's browser lands after OAuth, with `connected=<storeId>`
170
+ * or `connect_error=<code>` appended — a browser flow ends on a page, never on a JSON error.
171
+ */
125
172
  oauth?: {
126
173
  stateSecret: string;
127
174
  postConnectRedirect: string;
@@ -261,6 +308,18 @@ export declare function storeSlugSuffix(storeDomain: string): string;
261
308
  */
262
309
  export declare function isSlugConflict(error: unknown): boolean;
263
310
  export declare const CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS: number;
311
+ /** What a host writes as an order's `metadata.shippingAddress`, in {@link ChannelOrderAddress}'s spelling. */
312
+ export declare const channelOrderAddressSchema: z.ZodObject<{
313
+ firstName: z.ZodString;
314
+ lastName: z.ZodString;
315
+ line1: z.ZodString;
316
+ line2: z.ZodOptional<z.ZodString>;
317
+ city: z.ZodString;
318
+ region: z.ZodOptional<z.ZodString>;
319
+ postalCode: z.ZodOptional<z.ZodString>;
320
+ countryCode: z.ZodString;
321
+ phone: z.ZodOptional<z.ZodString>;
322
+ }, z.core.$strip>;
264
323
  /**
265
324
  * A hero is streamed inside the page's own invocation, so it is bounded: a 30 MB TIFF a merchant
266
325
  * uploaded by mistake must not buffer into a 128 MiB isolate. Anything larger is reported, not
@@ -419,15 +478,30 @@ export declare class ChannelConnectorService {
419
478
  getCatalogWriteSettings(orgId: string, storeId: string): Promise<PluginResult<CatalogWriteSettings>>;
420
479
  updateCatalogWriteEnabled(orgId: string, storeId: string, enabled: boolean): Promise<PluginResult<CatalogWriteSettings>>;
421
480
  updateCatalogFieldMapping(orgId: string, storeId: string, mapping: unknown): Promise<PluginResult<CatalogWriteSettings>>;
481
+ /**
482
+ * Connects a store, or refreshes the grant of one this organization already holds for the same
483
+ * provider and domain (a reconnect after an uninstall, or a re-authorization) — never a second row
484
+ * for the same shop, which would import it twice.
485
+ *
486
+ * The row and the consumer's binding of it ({@link BindConnectedStore}) are one transaction. A
487
+ * provider that subscribes per store is registered after commit, at an absolute address; the
488
+ * consumer's follow-on work ({@link AfterStoreConnected}) runs after that.
489
+ */
422
490
  connectStore(orgId: string, input: {
423
491
  provider: string;
424
492
  credentials: Record<string, unknown>;
425
493
  storeDomain: string;
426
494
  webhookSecret?: string;
427
- }): Promise<PluginResult<PublicConnectedStore>>;
428
- disconnectStore(orgId: string, id: string): Promise<PluginResult<PublicConnectedStore>>;
495
+ }, actor: StoreConnectActor): Promise<PluginResult<PublicConnectedStore>>;
496
+ /** The store with credentials good for a call the host makes itself, e.g. its own Admin API write. */
497
+ liveStore(orgId: string, storeId: string): Promise<PluginResult<ChannelStore>>;
498
+ /** The caller's allow-list, or null for unconfined. See {@link ConfineStores}. */
499
+ private allowedStores;
500
+ /** NOT_FOUND for a store outside the caller's set, exactly as for one that does not exist. */
501
+ reachableStore(orgId: string, id: string, context: StoreReadContext | undefined): Promise<PluginResult<ConnectedStore>>;
502
+ disconnectStore(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<PublicConnectedStore>>;
429
503
  disconnectStoreSystem(orgId: string, id: string, redactDomain?: boolean): Promise<PluginResult<PublicConnectedStore>>;
430
- getStore(orgId: string, id: string): Promise<PluginResult<PublicConnectedStore>>;
504
+ getStore(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<PublicConnectedStore>>;
431
505
  listStores(orgId: string, context?: StoreReadContext): Promise<PluginResult<PublicConnectedStore[]>>;
432
506
  validateLineStock(orgId: string, lines: ChannelStockLine[], timeoutMs?: number): Promise<void>;
433
507
  /**
@@ -537,6 +611,8 @@ export declare class ChannelConnectorService {
537
611
  private redactCustomerData;
538
612
  private redactShopData;
539
613
  private resolveOrderId;
614
+ /** Archives this store's product mapped to `externalId`, unless the platform owns its status. */
615
+ private archiveMappedProduct;
540
616
  private setMappedInventory;
541
617
  private setInventoryLevel;
542
618
  /** This store's mapped variant whose import recorded `metadata.inventoryItemId`; null when none, or when two claim it. */
package/dist/service.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
- import { CommerceInvalidTransitionError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, linkFieldPaths, removeEntityLinks, writeEntityLinks, } from "@porulle/core";
2
+ import { z } from "zod";
3
+ import { CommerceInvalidTransitionError, CommerceNotFoundError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, linkFieldPaths, removeEntityLinks, writeEntityLinks, } from "@porulle/core";
3
4
  import { isValidFieldPath, requireUserId } from "@porulle/core";
4
5
  import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
6
+ import { resolveLiveCredentials, withLiveCredentials } from "./live-credentials.js";
5
7
  import { and, desc, eq, inArray, isNull, lte, or, sql } from "@porulle/core/drizzle";
6
8
  import { brands, categories, customerAddresses, customers, entityBrands, entityCategories, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
7
9
  import { planAbsentArchives } from "./deletion-policy.js";
@@ -444,6 +446,28 @@ async function withTimeout(promise, timeoutMs) {
444
446
  clearTimeout(timer);
445
447
  }
446
448
  }
449
+ /** What a host writes as an order's `metadata.shippingAddress`, in {@link ChannelOrderAddress}'s spelling. */
450
+ export const channelOrderAddressSchema = z.object({
451
+ firstName: z.string(),
452
+ lastName: z.string(),
453
+ line1: z.string().min(1),
454
+ line2: z.string().optional(),
455
+ city: z.string().min(1),
456
+ region: z.string().optional(),
457
+ postalCode: z.string().optional(),
458
+ countryCode: z.string().regex(/^[A-Z]{2}$/, "countryCode must be ISO 3166-1 alpha-2"),
459
+ phone: z.string().optional(),
460
+ });
461
+ function withoutUndefined(address) {
462
+ const { line2, region, postalCode, phone, ...required } = address;
463
+ return {
464
+ ...required,
465
+ ...(line2 !== undefined ? { line2 } : {}),
466
+ ...(region !== undefined ? { region } : {}),
467
+ ...(postalCode !== undefined ? { postalCode } : {}),
468
+ ...(phone !== undefined ? { phone } : {}),
469
+ };
470
+ }
447
471
  function redactStore(store) {
448
472
  return {
449
473
  id: store.id,
@@ -605,7 +629,7 @@ export class ChannelConnectorService {
605
629
  if (this.connectors.has(connector.providerId)) {
606
630
  throw new Error(`Duplicate channel connector providerId: ${connector.providerId}`);
607
631
  }
608
- this.connectors.set(connector.providerId, connector);
632
+ this.connectors.set(connector.providerId, withLiveCredentials(connector, db));
609
633
  }
610
634
  this.jobs = options.jobs ?? services.jobs;
611
635
  this.transact = transaction ?? ((fn) => this.db.transaction(fn));
@@ -2179,59 +2203,107 @@ export class ChannelConnectorService {
2179
2203
  .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
2180
2204
  return this.getCatalogWriteSettings(orgId, storeId);
2181
2205
  }
2182
- async connectStore(orgId, input) {
2183
- if (!this.connectors.has(input.provider)) {
2206
+ /**
2207
+ * Connects a store, or refreshes the grant of one this organization already holds for the same
2208
+ * provider and domain (a reconnect after an uninstall, or a re-authorization) — never a second row
2209
+ * for the same shop, which would import it twice.
2210
+ *
2211
+ * The row and the consumer's binding of it ({@link BindConnectedStore}) are one transaction. A
2212
+ * provider that subscribes per store is registered after commit, at an absolute address; the
2213
+ * consumer's follow-on work ({@link AfterStoreConnected}) runs after that.
2214
+ */
2215
+ async connectStore(orgId, input, actor) {
2216
+ const connector = this.connectors.get(input.provider);
2217
+ if (!connector)
2184
2218
  return PluginErr(`No connector registered for provider "${input.provider}".`, "NOT_FOUND");
2219
+ const storeDomain = connector.normalizeStoreDomain ? connector.normalizeStoreDomain(input.storeDomain) : input.storeDomain;
2220
+ if (!storeDomain)
2221
+ return PluginErr(`"${input.storeDomain}" does not name a ${input.provider} store.`, "INVALID_STORE_DOMAIN");
2222
+ if (connector.registerWebhooks && !this.options.publicUrl) {
2223
+ return PluginErr(`Connector "${input.provider}" subscribes per store and needs the plugin's publicUrl to give it an absolute address.`, "PUBLIC_URL_REQUIRED");
2185
2224
  }
2186
- const existingRows = await this.db
2187
- .select()
2188
- .from(connectedStores)
2189
- .where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.provider, input.provider), eq(connectedStores.storeDomain, input.storeDomain)));
2190
- const reconnect = existingRows.find((row) => row.status !== "connected");
2191
- const rows = reconnect
2192
- ? await this.db
2193
- .update(connectedStores)
2194
- .set({
2195
- credentials: input.credentials,
2196
- status: "connected",
2197
- catalogWriteEnabled: false,
2198
- webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
2199
- updatedAt: new Date(),
2200
- })
2201
- .where(eq(connectedStores.id, reconnect.id))
2202
- .returning()
2203
- : await this.db
2204
- .insert(connectedStores)
2205
- .values({
2206
- organizationId: orgId,
2207
- provider: input.provider,
2208
- credentials: input.credentials,
2209
- storeDomain: input.storeDomain,
2210
- webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
2211
- })
2212
- .returning();
2213
- const connector = this.connectors.get(input.provider);
2214
- const store = rows[0];
2215
- if (connector.registerWebhooks) {
2225
+ let store;
2226
+ try {
2227
+ store = await this.transact(async (tx) => {
2228
+ const [existing] = await tx.select().from(connectedStores).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.provider, input.provider), eq(connectedStores.storeDomain, storeDomain)));
2229
+ const rows = existing
2230
+ ? await tx.update(connectedStores).set({
2231
+ credentials: input.credentials,
2232
+ status: "connected",
2233
+ ...(existing.status !== "connected" ? { catalogWriteEnabled: false } : {}),
2234
+ webhookSecret: input.webhookSecret ?? existing.webhookSecret ?? crypto.randomUUID(),
2235
+ updatedAt: new Date(),
2236
+ }).where(eq(connectedStores.id, existing.id)).returning()
2237
+ : await tx.insert(connectedStores).values({
2238
+ organizationId: orgId,
2239
+ provider: input.provider,
2240
+ credentials: input.credentials,
2241
+ storeDomain,
2242
+ webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
2243
+ }).returning();
2244
+ const written = rows[0];
2245
+ if (!written)
2246
+ throw new Error("The connected store row was not written.");
2247
+ await this.options.bindConnectedStore?.({ db: tx, store: written, actor });
2248
+ return written;
2249
+ });
2250
+ }
2251
+ catch (error) {
2252
+ return PluginErr(error instanceof Error ? error.message : "The store could not be connected.", error instanceof CommerceNotFoundError ? "NOT_FOUND" : "STORE_CONNECTION_REFUSED");
2253
+ }
2254
+ if (connector.registerWebhooks && this.options.publicUrl) {
2255
+ const callbackUrl = new URL(`/api/channels/webhooks/${store.id}`, this.options.publicUrl).toString();
2216
2256
  const registration = await connector.registerWebhooks(store, [
2257
+ "products/create",
2217
2258
  "products/update",
2218
2259
  "products/delete",
2219
2260
  "inventory_levels/update",
2220
2261
  "orders/fulfilled",
2221
2262
  "orders/cancelled",
2222
- "refunds/create",
2223
2263
  "app/uninstalled",
2224
- ], `/api/channels/webhooks/${store.id}`);
2264
+ ], callbackUrl);
2225
2265
  if (!registration.ok) {
2226
2266
  await this.db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
2227
2267
  return PluginErr(registration.error.message, "CONNECTOR_REGISTRATION_FAILED");
2228
2268
  }
2229
2269
  }
2230
- // Connecting starts no import. The host's operator route starts one (and levels inventory after
2231
- // it); connect used to enqueue a second, sequential walk that ran beside the host's own.
2270
+ if (this.options.afterStoreConnected) {
2271
+ try {
2272
+ await this.options.afterStoreConnected({ store, actor, connector });
2273
+ }
2274
+ catch (error) {
2275
+ return PluginErr(error instanceof Error ? error.message : "The store connected but its follow-on work failed.", "AFTER_CONNECT_FAILED");
2276
+ }
2277
+ }
2232
2278
  return Ok(redactStore(store));
2233
2279
  }
2234
- async disconnectStore(orgId, id) {
2280
+ /** The store with credentials good for a call the host makes itself, e.g. its own Admin API write. */
2281
+ async liveStore(orgId, storeId) {
2282
+ const store = await this.getStoreRecord(orgId, storeId);
2283
+ if (!store || store.status !== "connected")
2284
+ return PluginErr("Connected store not found.", "NOT_FOUND");
2285
+ const connector = this.connectors.get(store.provider);
2286
+ if (!connector)
2287
+ return PluginErr(`No connector registered for provider "${store.provider}".`, "NOT_FOUND");
2288
+ const live = await resolveLiveCredentials(connector, this.db, store);
2289
+ return live.ok ? Ok(live.value) : PluginErr(live.error.message, live.error.code);
2290
+ }
2291
+ /** The caller's allow-list, or null for unconfined. See {@link ConfineStores}. */
2292
+ async allowedStores(orgId, context) {
2293
+ return this.options.confineStores ? await this.options.confineStores(context ?? { orgId, actor: null, raw: undefined }) : null;
2294
+ }
2295
+ /** NOT_FOUND for a store outside the caller's set, exactly as for one that does not exist. */
2296
+ async reachableStore(orgId, id, context) {
2297
+ const allowed = await this.allowedStores(orgId, context);
2298
+ if (allowed !== null && !allowed.includes(id))
2299
+ return PluginErr("Connected store not found.", "NOT_FOUND");
2300
+ const store = await this.getStoreRecord(orgId, id);
2301
+ return store ? Ok(store) : PluginErr("Connected store not found.", "NOT_FOUND");
2302
+ }
2303
+ async disconnectStore(orgId, id, context) {
2304
+ const store = await this.reachableStore(orgId, id, context);
2305
+ if (!store.ok)
2306
+ return store;
2235
2307
  return this.disconnectStoreSystem(orgId, id);
2236
2308
  }
2237
2309
  async disconnectStoreSystem(orgId, id, redactDomain = false) {
@@ -2251,16 +2323,12 @@ export class ChannelConnectorService {
2251
2323
  return PluginErr("Connected store not found.", "NOT_FOUND");
2252
2324
  return Ok(redactStore(store));
2253
2325
  }
2254
- async getStore(orgId, id) {
2255
- const store = await this.getStoreRecord(orgId, id);
2256
- if (!store)
2257
- return PluginErr("Connected store not found.", "NOT_FOUND");
2258
- return Ok(redactStore(store));
2326
+ async getStore(orgId, id, context) {
2327
+ const store = await this.reachableStore(orgId, id, context);
2328
+ return store.ok ? Ok(redactStore(store.value)) : store;
2259
2329
  }
2260
2330
  async listStores(orgId, context) {
2261
- const allowed = this.options.confineStoreReads
2262
- ? await this.options.confineStoreReads(context ?? { orgId, actor: null, raw: undefined })
2263
- : null;
2331
+ const allowed = await this.allowedStores(orgId, context);
2264
2332
  // An empty allow-list means the caller may read NOTHING, stated here rather than left to the
2265
2333
  // query builder.
2266
2334
  //
@@ -3702,7 +3770,39 @@ export class ChannelConnectorService {
3702
3770
  let skipped = [];
3703
3771
  let conflicts = [];
3704
3772
  let warnings = [];
3705
- if (event.type === "products/update") {
3773
+ const connector = this.connectors.get(store.provider);
3774
+ if ((event.type === "products/create" || event.type === "products/update") && connector?.fetchCatalogItems) {
3775
+ // A webhook is a notification, not a snapshot: its payload is the provider's wire spelling and
3776
+ // may be stale or out of order. The product is read fresh and converged exactly as an import
3777
+ // page would be — creating it when this store has never mapped it.
3778
+ const externalId = String(data.id ?? data.product_id ?? "");
3779
+ if (!externalId)
3780
+ return PluginErr(`A ${event.type} delivery named no product.`, "INVALID_WEBHOOK");
3781
+ const read = await connector.fetchCatalogItems(store, [externalId]);
3782
+ if (!read.ok)
3783
+ return PluginErr(read.error.message, read.error.code);
3784
+ const [item] = read.value;
3785
+ if (item === undefined) {
3786
+ // Gone between the delivery and the read: the same as a delete.
3787
+ const archived = await this.archiveMappedProduct(orgId, storeId, externalId, actor);
3788
+ if (!archived.ok)
3789
+ return archived;
3790
+ skipped = archived.value;
3791
+ }
3792
+ else {
3793
+ const converged = await this.convergeCatalogPage(orgId, storeId, [item], actor);
3794
+ if (!converged.ok)
3795
+ return converged;
3796
+ const [failure] = converged.value.failures;
3797
+ if (failure)
3798
+ return PluginErr(`Product ${failure.externalId} could not be converged: ${failure.error}`, "CONVERGENCE_FAILED");
3799
+ warnings = [...converged.value.warnings];
3800
+ if (converged.value.entityIds.length > 0) {
3801
+ await this.options.onStoreCatalogChanged?.({ orgId, storeId, entityIds: [...converged.value.entityIds], convergence: converged.value });
3802
+ }
3803
+ }
3804
+ }
3805
+ else if (event.type === "products/update") {
3706
3806
  const productId = String(data.id ?? data.product_id ?? "");
3707
3807
  const mapping = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, productId)));
3708
3808
  if (mapping[0]) {
@@ -3715,19 +3815,10 @@ export class ChannelConnectorService {
3715
3815
  }
3716
3816
  }
3717
3817
  else if (event.type === "products/delete") {
3718
- const productId = String(data.id ?? data.product_id ?? "");
3719
- const mapping = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, productId)));
3720
- if (mapping[0]) {
3721
- const owners = await this.catalog.resolveFieldOwners(mapping[0].entityId, storeId);
3722
- if (owners.get("entity.status") === "platform") {
3723
- skipped.push({ entityId: mapping[0].entityId, fieldPath: "entity.status" });
3724
- }
3725
- else {
3726
- const archived = await this.catalog.archive(mapping[0].entityId, actor);
3727
- if (!archived.ok)
3728
- return PluginErr(archived.error.message);
3729
- }
3730
- }
3818
+ const archived = await this.archiveMappedProduct(orgId, storeId, String(data.id ?? data.product_id ?? ""), actor);
3819
+ if (!archived.ok)
3820
+ return archived;
3821
+ skipped = archived.value;
3731
3822
  }
3732
3823
  else if (event.type === "inventory_levels/update") {
3733
3824
  const available = Number(data.available ?? data.stock_quantity ?? 0);
@@ -3738,7 +3829,18 @@ export class ChannelConnectorService {
3738
3829
  const inventoryItemId = data.inventory_item_id !== undefined && data.inventory_item_id !== null ? String(data.inventory_item_id) : null;
3739
3830
  const byInventoryItem = inventoryItemId === null ? null : await this.variantForInventoryItem(orgId, storeId, inventoryItemId);
3740
3831
  if (byInventoryItem !== null) {
3741
- await this.setInventoryLevel(byInventoryItem.entityId, byInventoryItem.variantId, available, actor);
3832
+ // The delivery's `available` is ONE location's count. The variant's stock is the sum the
3833
+ // connector reads, so it is read fresh rather than taken from the payload.
3834
+ let quantity = available;
3835
+ if (connector) {
3836
+ const levels = await connector.fetchInventory(store, [byInventoryItem.externalId]);
3837
+ if (!levels.ok)
3838
+ return PluginErr(levels.error.message, levels.error.code);
3839
+ const fresh = levels.value.find((entry) => entry.externalId === byInventoryItem.externalId);
3840
+ if (fresh)
3841
+ quantity = fresh.available;
3842
+ }
3843
+ await this.setInventoryLevel(byInventoryItem.entityId, byInventoryItem.variantId, quantity, actor);
3742
3844
  }
3743
3845
  else {
3744
3846
  const externalId = String(data.variation_id ?? data.product_id ?? inventoryItemId ?? "");
@@ -3845,10 +3947,22 @@ export class ChannelConnectorService {
3845
3947
  }
3846
3948
  async resolveOrderId(orgId, storeId, data) {
3847
3949
  const nestedOrder = data.order && typeof data.order === "object" ? data.order : undefined;
3848
- const remoteOrderId = String(data.order_id ?? data.orderId ?? nestedOrder?.id ?? "");
3950
+ // An `orders/*` payload IS the order, so its own `id` names it; a refund names its order in `order_id`.
3951
+ const remoteOrderId = String(data.order_id ?? data.orderId ?? nestedOrder?.id ?? data.id ?? "");
3849
3952
  const rows = await this.db.select({ orderId: channelOrderExports.orderId }).from(channelOrderExports).where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.storeId, storeId), eq(channelOrderExports.remoteOrderId, remoteOrderId)));
3850
3953
  return rows[0]?.orderId;
3851
3954
  }
3955
+ /** Archives this store's product mapped to `externalId`, unless the platform owns its status. */
3956
+ async archiveMappedProduct(orgId, storeId, externalId, actor) {
3957
+ const [mapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.externalId, externalId)));
3958
+ if (!mapping)
3959
+ return Ok([]);
3960
+ const owners = await this.catalog.resolveFieldOwners(mapping.entityId, storeId);
3961
+ if (owners.get("entity.status") === "platform")
3962
+ return Ok([{ entityId: mapping.entityId, fieldPath: "entity.status" }]);
3963
+ const archived = await this.catalog.archive(mapping.entityId, actor);
3964
+ return archived.ok ? Ok([]) : PluginErr(archived.error.message);
3965
+ }
3852
3966
  async setMappedInventory(orgId, storeId, externalId, quantity, actor) {
3853
3967
  const [mapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.externalId, externalId)));
3854
3968
  if (!mapping)
@@ -3861,12 +3975,12 @@ export class ChannelConnectorService {
3861
3975
  }
3862
3976
  /** This store's mapped variant whose import recorded `metadata.inventoryItemId`; null when none, or when two claim it. */
3863
3977
  async variantForInventoryItem(orgId, storeId, inventoryItemId) {
3864
- const rows = await this.db.select({ entityId: channelEntityMap.entityId, variantId: channelEntityMap.variantId }).from(channelEntityMap)
3978
+ const rows = await this.db.select({ entityId: channelEntityMap.entityId, variantId: channelEntityMap.variantId, externalId: channelEntityMap.externalId }).from(channelEntityMap)
3865
3979
  .innerJoin(variants, eq(variants.id, channelEntityMap.variantId))
3866
3980
  .where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), sql `${variants.metadata}->>'inventoryItemId' = ${inventoryItemId}`))
3867
3981
  .limit(2);
3868
3982
  const [only] = rows;
3869
- return rows.length === 1 && only !== undefined && only.variantId !== null ? { entityId: only.entityId, variantId: only.variantId } : null;
3983
+ return rows.length === 1 && only !== undefined && only.variantId !== null ? { entityId: only.entityId, variantId: only.variantId, externalId: only.externalId } : null;
3870
3984
  }
3871
3985
  async convergeCatalogItem(orgId, storeId, entityId, data, actor) {
3872
3986
  const product = data.product && typeof data.product === "object" ? data.product : data;
@@ -4217,8 +4331,19 @@ export class ChannelConnectorService {
4217
4331
  // shipped to their default. The order's address is applied below and wins.
4218
4332
  const addresses = await this.db.select().from(customerAddresses).where(and(eq(customerAddresses.customerId, customer.id), eq(customerAddresses.type, "shipping")));
4219
4333
  const address = addresses.find((item) => item.isDefault) ?? addresses[0];
4220
- if (address)
4221
- shippingAddress = { first_name: address.firstName, last_name: address.lastName, address1: address.line1, ...(address.line2 ? { address2: address.line2 } : {}), city: address.city, ...(address.state ? { state: address.state } : {}), ...(address.postalCode ? { zip: address.postalCode } : {}), country: address.country, ...(address.phone ? { phone: address.phone } : {}) };
4334
+ if (address) {
4335
+ shippingAddress = {
4336
+ firstName: address.firstName ?? "",
4337
+ lastName: address.lastName ?? "",
4338
+ line1: address.line1,
4339
+ ...(address.line2 ? { line2: address.line2 } : {}),
4340
+ city: address.city,
4341
+ ...(address.state ? { region: address.state } : {}),
4342
+ ...(address.postalCode ? { postalCode: address.postalCode } : {}),
4343
+ countryCode: address.country,
4344
+ ...(address.phone ? { phone: address.phone } : {}),
4345
+ };
4346
+ }
4222
4347
  }
4223
4348
  }
4224
4349
  const metadata = order.metadata ?? {};
@@ -4226,8 +4351,12 @@ export class ChannelConnectorService {
4226
4351
  email ??= typeof guest.email === "string" ? guest.email : null;
4227
4352
  name ||= typeof guest.name === "string" ? guest.name : `${typeof guest.firstName === "string" ? guest.firstName : ""} ${typeof guest.lastName === "string" ? guest.lastName : ""}`.trim();
4228
4353
  const orderShipping = metadata.shippingAddress ?? metadata.guestShippingAddress ?? (typeof metadata.guestCustomer === "object" && metadata.guestCustomer ? metadata.guestCustomer.shippingAddress : undefined);
4229
- if (orderShipping && typeof orderShipping === "object")
4230
- shippingAddress = orderShipping;
4354
+ if (orderShipping !== undefined) {
4355
+ const parsed = channelOrderAddressSchema.safeParse(orderShipping);
4356
+ if (!parsed.success)
4357
+ return PluginErr(`The order's shipping address is not a channel order address: ${parsed.error.issues[0]?.message ?? "invalid"}.`, "CUSTOMER_DATA_MISSING");
4358
+ shippingAddress = withoutUndefined(parsed.data);
4359
+ }
4231
4360
  if (!email || !shippingAddress)
4232
4361
  return PluginErr("Customer email and shipping address are required for channel order export.", "CUSTOMER_DATA_MISSING");
4233
4362
  return Ok({ orderId, currency: order.currency, grandTotal: lines.reduce((sum, line) => sum + line.totalPrice, 0), lines, customer: { name, email, shippingAddress } });
package/package.json CHANGED
@@ -1,33 +1,36 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.65.0",
3
+ "version": "0.66.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {
8
- "bun": "./src/index.ts",
9
- "import": "./dist/index.js",
10
- "types": "./src/index.ts"
8
+ "@porulle/source": "./src/index.ts",
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
11
  },
12
12
  "./schema": {
13
- "bun": "./src/schema.ts",
14
- "import": "./dist/schema.js",
15
- "require": "./dist/schema.js",
16
- "types": "./src/schema.ts"
17
- }
13
+ "@porulle/source": "./src/schema.ts",
14
+ "types": "./dist/schema.d.ts",
15
+ "default": "./dist/schema.js"
16
+ },
17
+ "./package.json": "./package.json"
18
+ },
19
+ "engines": {
20
+ "node": ">=20.19.0"
18
21
  },
19
22
  "dependencies": {
20
23
  "@hono/zod-openapi": "^1.2.2",
21
24
  "hono": "^4.12.5",
22
- "@porulle/core": "0.65.0"
25
+ "@porulle/core": "0.66.0"
23
26
  },
24
27
  "devDependencies": {
25
28
  "@types/node": "^24.5.2",
26
29
  "eslint": "^9.39.1",
27
30
  "typescript": "5.9.2",
28
31
  "vitest": "^3.2.4",
29
- "@porulle/typescript-config": "0.1.0",
30
- "@porulle/eslint-config": "0.1.0"
32
+ "@porulle/eslint-config": "0.1.0",
33
+ "@porulle/typescript-config": "0.1.0"
31
34
  },
32
35
  "publishConfig": {
33
36
  "access": "public"