@porulle/plugin-channel-connector 0.73.2 → 0.74.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/src/service.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { z } from "zod";
3
3
  import {
4
+ CHANNEL_CREDENTIALS_REJECTED,
4
5
  CHANNEL_OUT_OF_STOCK,
5
6
  CommerceInvalidTransitionError,
6
7
  CommerceNotFoundError,
@@ -19,6 +20,9 @@ import type {
19
20
  EntityLinkRows,
20
21
  ChannelCatalogItem,
21
22
  ChannelConnector,
23
+ ChannelEvent,
24
+ ChannelShipment,
25
+ ChannelWebhookEvent,
22
26
  ChannelOrderAddress,
23
27
  ChannelOrderSlice,
24
28
  ChannelPushCatalogField,
@@ -92,6 +96,7 @@ import {
92
96
  type ChannelRefundRequest,
93
97
  type ConnectedStore,
94
98
  } from "./schema.js";
99
+ import type { StoreHealth } from "./schema.js";
95
100
  import {
96
101
  mergeCatalogFieldMapping,
97
102
  normalizeCatalogFieldMapping,
@@ -132,26 +137,13 @@ export const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
132
137
  * The reason a platform order is cancelled under when its STORE cancelled it. The cancel hook skips
133
138
  * cancelling at the store for exactly this reason, so the two directions cannot loop.
134
139
  */
135
- export const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
140
+ /** How stale a store order may be before a read point queues a refresh of it. */
141
+ export const REMOTE_ORDER_REFRESH_MS = 5 * 60 * 1000;
136
142
 
137
- /** What each store return webhook says about the return. */
138
- const RETURN_WEBHOOK_STATUS: Partial<Record<string, "approved" | "declined" | "closed" | "cancelled" | "requested">> = {
139
- "returns/approve": "approved",
140
- "returns/decline": "declined",
141
- "returns/close": "closed",
142
- "returns/cancel": "cancelled",
143
- "returns/reopen": "approved",
144
- };
143
+ /** How often a merchant's visit may make the plugin call a store to check its health. */
144
+ export const STORE_HEALTH_INTERVAL_MS = 10 * 60 * 1000;
145
145
 
146
- /** The slice of a store order body's `fulfillments` (Shopify's REST spelling) a parcel is read from. */
147
- const channelFulfillmentsSchema = z.array(z.object({
148
- id: z.union([z.string(), z.number()]),
149
- status: z.string().nullish(),
150
- tracking_company: z.string().nullish(),
151
- tracking_number: z.string().nullish(),
152
- tracking_url: z.string().nullish(),
153
- line_items: z.array(z.object({ variant_id: z.union([z.string(), z.number()]).nullish(), quantity: z.number().int().positive() })).default([]),
154
- }));
146
+ export const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
155
147
 
156
148
  const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
157
149
  const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
@@ -1117,6 +1109,9 @@ function redactStore(store: ConnectedStore): PublicConnectedStore {
1117
1109
  credentials: "[REDACTED]",
1118
1110
  storeDomain: store.storeDomain,
1119
1111
  status: store.status,
1112
+ statusReason: store.statusReason,
1113
+ health: store.health,
1114
+ lastEventAt: store.lastEventAt,
1120
1115
  catalogWriteEnabled: store.catalogWriteEnabled,
1121
1116
  catalogFieldMapping: store.catalogFieldMapping,
1122
1117
  catalogCursor: store.catalogCursor,
@@ -1165,6 +1160,10 @@ export interface CatalogPageConvergence extends Record<string, unknown> {
1165
1160
  heroesImported: number;
1166
1161
  mediaFailures: CatalogMediaFailure[];
1167
1162
  deferredMedia: CatalogDeferredMedia[];
1163
+ /** Fields the store's value did not overwrite because the platform owns them. */
1164
+ skipped: CatalogFieldSkip[];
1165
+ /** Shared fields both sides changed, held for an operator. */
1166
+ conflicts: CatalogFieldConflict[];
1168
1167
  warnings: string[];
1169
1168
  }
1170
1169
 
@@ -1295,6 +1294,13 @@ function toImportProduct(item: ChannelCatalogItem): ImportProduct {
1295
1294
  /** One item's link writes, planned before the transaction that commits them (`commitEntityLinks`). */
1296
1295
  type PlannedLinks = { [K in keyof EntityLinkRows]-?: Array<NonNullable<EntityLinkRows[K]>[number]> };
1297
1296
 
1297
+ /** What applying one delivery's events left for the store's reconcile report. */
1298
+ interface ChannelEventReport {
1299
+ skipped: CatalogFieldSkip[];
1300
+ conflicts: CatalogFieldConflict[];
1301
+ warnings: string[];
1302
+ }
1303
+
1298
1304
  export class ChannelConnectorService {
1299
1305
  private readonly connectors = new Map<string, ChannelConnector>();
1300
1306
  private readonly transact: PluginTxFn;
@@ -2654,6 +2660,12 @@ export class ChannelConnectorService {
2654
2660
 
2655
2661
  // A shop_domain can map to more than one connected store (reconnect, or the same
2656
2662
  // shop under two orgs). Compliance webhooks must fan out to all of them.
2663
+ /** This organization's store of `provider` at `storeDomain`, whatever its status. */
2664
+ async storeByDomain(orgId: string, provider: string, storeDomain: string): Promise<PublicConnectedStore | undefined> {
2665
+ const [row] = await this.db.select().from(connectedStores).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.provider, provider), eq(connectedStores.storeDomain, storeDomain)));
2666
+ return row ? redactStore(row as ConnectedStore) : undefined;
2667
+ }
2668
+
2657
2669
  async getStoresByDomain(shopDomain: string): Promise<ConnectedStore[]> {
2658
2670
  const rows = await this.db
2659
2671
  .select()
@@ -3156,14 +3168,29 @@ export class ChannelConnectorService {
3156
3168
  * provider that subscribes per store is registered after commit, at an absolute address; the
3157
3169
  * consumer's follow-on work ({@link AfterStoreConnected}) runs after that.
3158
3170
  */
3171
+ /**
3172
+ * Connects a store and runs its follow-on work (subscribe, first import) before answering. For a
3173
+ * provider whose callback must be answered at once, see {@link saveConnectingStore} and
3174
+ * {@link completeConnect}, which this is the two halves of.
3175
+ */
3159
3176
  async connectStore(
3160
3177
  orgId: string,
3161
- input: {
3162
- provider: string;
3163
- credentials: Record<string, unknown>;
3164
- storeDomain: string;
3165
- webhookSecret?: string;
3166
- },
3178
+ input: { provider: string; credentials: Record<string, unknown>; storeDomain: string; webhookSecret?: string },
3179
+ actor: StoreConnectActor,
3180
+ ): Promise<PluginResult<PublicConnectedStore>> {
3181
+ const saved = await this.saveConnectingStore(orgId, input, actor);
3182
+ if (!saved.ok) return saved;
3183
+ return this.completeConnect(orgId, saved.value.id, actor);
3184
+ }
3185
+
3186
+ /**
3187
+ * Writes the store with its credentials in status `connecting`, bound to the actor's vendor, and
3188
+ * nothing else: no call to the store. Reconnecting a store this organization already has refreshes
3189
+ * its row instead of adding one.
3190
+ */
3191
+ async saveConnectingStore(
3192
+ orgId: string,
3193
+ input: { provider: string; credentials: Record<string, unknown>; storeDomain: string; webhookSecret?: string },
3167
3194
  actor: StoreConnectActor,
3168
3195
  ): Promise<PluginResult<PublicConnectedStore>> {
3169
3196
  const connector = this.connectors.get(input.provider);
@@ -3173,9 +3200,8 @@ export class ChannelConnectorService {
3173
3200
  if (connector.registerWebhooks && !this.options.publicUrl) {
3174
3201
  return PluginErr(`Connector "${input.provider}" subscribes per store and needs the plugin's publicUrl to give it an absolute address.`, "PUBLIC_URL_REQUIRED");
3175
3202
  }
3176
- let store: ConnectedStore;
3177
3203
  try {
3178
- store = await this.transact(async (tx) => {
3204
+ const store = await this.transact(async (tx) => {
3179
3205
  const [existing] = await tx.select().from(connectedStores).where(and(
3180
3206
  eq(connectedStores.organizationId, orgId),
3181
3207
  eq(connectedStores.provider, input.provider),
@@ -3184,7 +3210,8 @@ export class ChannelConnectorService {
3184
3210
  const rows = existing
3185
3211
  ? await tx.update(connectedStores).set({
3186
3212
  credentials: input.credentials,
3187
- status: "connected",
3213
+ status: "connecting",
3214
+ statusReason: null,
3188
3215
  ...(existing.status !== "connected" ? { catalogWriteEnabled: false } : {}),
3189
3216
  webhookSecret: input.webhookSecret ?? existing.webhookSecret ?? crypto.randomUUID(),
3190
3217
  updatedAt: new Date(),
@@ -3194,6 +3221,7 @@ export class ChannelConnectorService {
3194
3221
  provider: input.provider,
3195
3222
  credentials: input.credentials,
3196
3223
  storeDomain,
3224
+ status: "connecting",
3197
3225
  webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
3198
3226
  }).returning();
3199
3227
  const written = rows[0] as ConnectedStore | undefined;
@@ -3201,39 +3229,53 @@ export class ChannelConnectorService {
3201
3229
  await this.options.bindConnectedStore?.({ db: tx, store: written, actor });
3202
3230
  return written;
3203
3231
  });
3232
+ return Ok(redactStore(store));
3204
3233
  } catch (error) {
3205
3234
  return PluginErr(error instanceof Error ? error.message : "The store could not be connected.", error instanceof CommerceNotFoundError ? "NOT_FOUND" : "STORE_CONNECTION_REFUSED");
3206
3235
  }
3236
+ }
3237
+
3238
+ /**
3239
+ * The work after the credentials are saved: subscribe the store to its connector's topics (all or
3240
+ * none — a partial subscription is removed), mark it `connected`, then the host's follow-on work.
3241
+ * A failure leaves the store in `error` with the reason the merchant will read, unless the host
3242
+ * already moved it (it disconnects a store it refuses).
3243
+ */
3244
+ async completeConnect(orgId: string, storeId: string, actor: StoreConnectActor): Promise<PluginResult<PublicConnectedStore>> {
3245
+ const store = await this.getStoreRecord(orgId, storeId);
3246
+ if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
3247
+ const connector = this.connectors.get(store.provider);
3248
+ if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`, "NOT_FOUND");
3249
+ const fail = async (message: string, code: string): Promise<PluginResult<never>> => {
3250
+ await this.db.update(connectedStores).set({ statusReason: message, updatedAt: new Date() }).where(eq(connectedStores.id, storeId));
3251
+ await this.db.update(connectedStores).set({ status: "error" }).where(and(eq(connectedStores.id, storeId), inArray(connectedStores.status, ["connecting", "connected"])));
3252
+ return PluginErr(message, code);
3253
+ };
3207
3254
  if (connector.registerWebhooks && this.options.publicUrl) {
3208
- const callbackUrl = new URL(`/api/channels/webhooks/${store.id}`, this.options.publicUrl).toString();
3209
- const registration = await connector.registerWebhooks(store as ChannelStore, [
3210
- "products/create",
3211
- "products/update",
3212
- "products/delete",
3213
- "inventory_levels/update",
3214
- "orders/fulfilled",
3215
- "orders/partially_fulfilled",
3216
- "orders/cancelled",
3217
- "refunds/create",
3218
- "returns/approve",
3219
- "returns/decline",
3220
- "returns/close",
3221
- "returns/cancel",
3222
- "app/uninstalled",
3223
- ], callbackUrl);
3255
+ const callbackUrl = this.webhookCallbackUrl(storeId);
3256
+ const registration = await connector.registerWebhooks(store as ChannelStore, [...(connector.webhookTopics ?? [])], callbackUrl);
3224
3257
  if (!registration.ok) {
3225
- await this.db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
3226
- return PluginErr(registration.error.message, "CONNECTOR_REGISTRATION_FAILED");
3258
+ await connector.unregisterWebhooks?.(store as ChannelStore, callbackUrl);
3259
+ return fail(registration.error.message, registration.error.code === CHANNEL_CREDENTIALS_REJECTED ? CHANNEL_CREDENTIALS_REJECTED : "CONNECTOR_REGISTRATION_FAILED");
3227
3260
  }
3228
3261
  }
3262
+ const [connected] = await this.db.update(connectedStores).set({ status: "connected", statusReason: null, updatedAt: new Date() })
3263
+ .where(and(eq(connectedStores.id, storeId), eq(connectedStores.status, "connecting"))).returning();
3264
+ const current = (connected ?? store) as ConnectedStore;
3229
3265
  if (this.options.afterStoreConnected) {
3230
3266
  try {
3231
- await this.options.afterStoreConnected({ store, actor, connector, services: this.services });
3267
+ await this.options.afterStoreConnected({ store: current, actor, connector, services: this.services });
3232
3268
  } catch (error) {
3233
- return PluginErr(error instanceof Error ? error.message : "The store connected but its follow-on work failed.", "AFTER_CONNECT_FAILED");
3269
+ return fail(error instanceof Error ? error.message : "The store connected but its follow-on work failed.", "AFTER_CONNECT_FAILED");
3234
3270
  }
3235
3271
  }
3236
- return Ok(redactStore(store));
3272
+ return Ok(redactStore(current));
3273
+ }
3274
+
3275
+ /** Where a store that subscribes per store delivers its webhooks: absolute, on this deployment's public origin. */
3276
+ webhookCallbackUrl(storeId: string): string {
3277
+ if (!this.options.publicUrl) throw new Error("The channel plugin has no publicUrl to build a webhook address on.");
3278
+ return new URL(`/api/channels/webhooks/${storeId}`, this.options.publicUrl).toString();
3237
3279
  }
3238
3280
 
3239
3281
  /** The store with credentials good for a call the host makes itself, e.g. its own Admin API write. */
@@ -3276,6 +3318,13 @@ export class ChannelConnectorService {
3276
3318
  }
3277
3319
 
3278
3320
  async disconnectStoreSystem(orgId: string, id: string, redactDomain = false): Promise<PluginResult<PublicConnectedStore>> {
3321
+ const before = await this.getStoreRecord(orgId, id);
3322
+ const connector = before ? this.connectors.get(before.provider) : undefined;
3323
+ if (before && connector?.unregisterWebhooks && this.options.publicUrl && before.status !== "disconnected") {
3324
+ // Best effort: the store is disconnected here whatever the provider answers.
3325
+ const removed = await connector.unregisterWebhooks(before as ChannelStore, this.webhookCallbackUrl(id));
3326
+ if (!removed.ok) console.warn(JSON.stringify({ event: "channel_unregister_webhooks_failed", provider: before.provider, storeId: id, code: removed.error.code, message: removed.error.message }));
3327
+ }
3279
3328
  const rows = await this.db
3280
3329
  .update(connectedStores)
3281
3330
  .set({
@@ -3292,6 +3341,39 @@ export class ChannelConnectorService {
3292
3341
  return Ok(redactStore(store));
3293
3342
  }
3294
3343
 
3344
+ /**
3345
+ * Checks the store's webhook subscriptions and key and repairs what it can, when a merchant looks:
3346
+ * there is no scheduled check. At most once per {@link STORE_HEALTH_INTERVAL_MS} per store; inside
3347
+ * that window the last result is answered without calling the store.
3348
+ */
3349
+ async checkStoreHealth(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<StoreHealth & { status: ConnectedStore["status"]; statusReason: string | null; lastEventAt: Date | null; cached: boolean }>> {
3350
+ const reached = await this.reachableStore(orgId, id, context);
3351
+ if (!reached.ok) return reached;
3352
+ const store = reached.value;
3353
+ const answer = (health: StoreHealth, current: ConnectedStore, cached: boolean) => Ok({ ...health, status: current.status, statusReason: current.statusReason, lastEventAt: current.lastEventAt, cached });
3354
+ if (store.health && Date.now() - Date.parse(store.health.checkedAt) < STORE_HEALTH_INTERVAL_MS) return answer(store.health, store, true);
3355
+ const connector = this.connectors.get(store.provider);
3356
+ if (!connector) return PluginErr(`No connector registered for provider "${store.provider}".`, "NOT_FOUND");
3357
+ let health: StoreHealth;
3358
+ const checkedAt = new Date().toISOString();
3359
+ if (store.status !== "connected" && store.status !== "error") {
3360
+ health = { checkedAt, webhooks: "not_applicable", repaired: 0, missing: [], keyValid: false };
3361
+ } else if (connector.webhookHealth && this.options.publicUrl) {
3362
+ const checked = await connector.webhookHealth(store as ChannelStore, this.webhookCallbackUrl(id));
3363
+ health = checked.ok
3364
+ ? { checkedAt, webhooks: checked.value.healthy ? (checked.value.repaired > 0 ? "repaired" : "ok") : "failing", repaired: checked.value.repaired, missing: checked.value.missing, keyValid: true }
3365
+ : { checkedAt, webhooks: "failing", repaired: 0, missing: [...(connector.webhookTopics ?? [])], keyValid: checked.error.code !== CHANNEL_CREDENTIALS_REJECTED, error: checked.error.message };
3366
+ } else if (connector.fetchStoreProfile) {
3367
+ // Subscribed per app (Shopify): nothing per store to repair, only the grant to check.
3368
+ const profile = await connector.fetchStoreProfile(store as ChannelStore);
3369
+ health = { checkedAt, webhooks: "not_applicable", repaired: 0, missing: [], keyValid: profile.ok || profile.error.code !== CHANNEL_CREDENTIALS_REJECTED, ...(profile.ok ? {} : { error: profile.error.message }) };
3370
+ } else {
3371
+ health = { checkedAt, webhooks: "not_applicable", repaired: 0, missing: [], keyValid: true };
3372
+ }
3373
+ const [updated] = await this.db.update(connectedStores).set({ health, updatedAt: new Date() }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, id))).returning();
3374
+ return answer(health, (updated ?? store) as ConnectedStore, false);
3375
+ }
3376
+
3295
3377
  async getStore(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<PublicConnectedStore>> {
3296
3378
  const store = await this.reachableStore(orgId, id, context);
3297
3379
  return store.ok ? Ok(redactStore(store.value)) : store;
@@ -3426,6 +3508,8 @@ export class ChannelConnectorService {
3426
3508
  const items = rawItems.map(withDistinctVariantSkus);
3427
3509
  const failures: CatalogConvergenceFailure[] = [];
3428
3510
  const warnings: string[] = [];
3511
+ const skipped: CatalogFieldSkip[] = [];
3512
+ const conflicts: CatalogFieldConflict[] = [];
3429
3513
  const entityByExternalId = new Map<string, string>();
3430
3514
  let unchanged = 0;
3431
3515
  let updated = 0;
@@ -3473,6 +3557,8 @@ export class ChannelConnectorService {
3473
3557
  if (!result.ok) return result;
3474
3558
  failures.push(...result.value.failures);
3475
3559
  warnings.push(...result.value.warnings);
3560
+ skipped.push(...result.value.skipped);
3561
+ conflicts.push(...result.value.conflicts);
3476
3562
  const failedIds = new Set(result.value.failures.map((failure) => failure.externalId));
3477
3563
  const survivors = editor.filter((item) => !failedIds.has(item.externalId));
3478
3564
  survivors.forEach((item, index) => {
@@ -3563,6 +3649,8 @@ export class ChannelConnectorService {
3563
3649
  heroesImported: media.heroesImported,
3564
3650
  mediaFailures: media.mediaFailures,
3565
3651
  deferredMedia: media.deferredMedia,
3652
+ skipped,
3653
+ conflicts,
3566
3654
  warnings,
3567
3655
  });
3568
3656
  }
@@ -4944,83 +5032,103 @@ export class ChannelConnectorService {
4944
5032
  return Ok({ synced, exhausted });
4945
5033
  }
4946
5034
 
4947
- async handleWebhook(orgId: string, storeId: string, event: { id: string; type: string; data: unknown }): Promise<PluginResult<{ processed: true; data?: ChannelComplianceData; redacted?: number }>> {
5035
+ /**
5036
+ * One verified store delivery. The store's connector says what it means ({@link ChannelEvent}); this
5037
+ * acts on the meaning and never on a topic or payload field. A delivery the connector does not act
5038
+ * on is logged as unmapped and answered `processed: false`, never reported as applied.
5039
+ */
5040
+ async handleWebhook(orgId: string, storeId: string, delivery: ChannelWebhookEvent): Promise<PluginResult<{ processed: boolean; data?: ChannelComplianceData; redacted?: number }>> {
4948
5041
  const store = await this.getStoreRecord(orgId, storeId);
4949
5042
  if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
4950
- const actor = createSystemActor(orgId);
4951
- const data = event.data as Record<string, unknown>;
4952
- let skipped: CatalogFieldSkip[] = [];
4953
- let conflicts: CatalogFieldConflict[] = [];
4954
- let warnings: string[] = [];
4955
5043
  const connector = this.connectors.get(store.provider);
4956
- if ((event.type === "products/create" || event.type === "products/update") && connector?.fetchCatalogItems) {
4957
- // A webhook is a notification, not a snapshot: its payload is the provider's wire spelling and
4958
- // may be stale or out of order. The product is read fresh and converged exactly as an import
4959
- // page would be — creating it when this store has never mapped it.
4960
- const externalId = String(data.id ?? data.product_id ?? "");
4961
- if (!externalId) return PluginErr(`A ${event.type} delivery named no product.`, "INVALID_WEBHOOK");
4962
- const read = await connector.fetchCatalogItems(store as ChannelStore, [externalId]);
4963
- if (!read.ok) return PluginErr(read.error.message, read.error.code);
4964
- const [item] = read.value;
4965
- if (item === undefined) {
4966
- // Gone between the delivery and the read: the same as a delete.
4967
- const archived = await this.archiveMappedProduct(orgId, storeId, externalId, actor);
4968
- if (!archived.ok) return archived;
4969
- skipped = archived.value;
4970
- } else {
4971
- const converged = await this.convergeCatalogPage(orgId, storeId, [item], actor);
5044
+ const decoded = connector?.decodeWebhook ? await connector.decodeWebhook(store as ChannelStore, delivery) : Ok<ChannelEvent[]>([]);
5045
+ if (!decoded.ok) return PluginErr(decoded.error.message, decoded.error.code);
5046
+ await this.db.update(connectedStores).set({ lastEventAt: new Date() }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
5047
+ if (decoded.value.length === 0) {
5048
+ console.warn(JSON.stringify({ event: "channel_webhook_unmapped", provider: store.provider, storeId, topic: delivery.type, deliveryId: delivery.id }));
5049
+ return Ok({ processed: false });
5050
+ }
5051
+ const actor = createSystemActor(orgId);
5052
+ const outcome: { processed: boolean; data?: ChannelComplianceData; redacted?: number } = { processed: true };
5053
+ const report: ChannelEventReport = { skipped: [], conflicts: [], warnings: [] };
5054
+ for (const event of decoded.value) {
5055
+ const applied = await this.applyChannelEvent(orgId, store, connector, event, actor, report);
5056
+ if (!applied.ok) return applied;
5057
+ if (applied.value.data) outcome.data = applied.value.data;
5058
+ if (applied.value.redacted !== undefined) outcome.redacted = applied.value.redacted;
5059
+ }
5060
+ if (report.skipped.length > 0 || report.conflicts.length > 0 || report.warnings.length > 0) {
5061
+ const merged = {
5062
+ ...(store.lastReconcileReport ?? {}),
5063
+ ...(report.skipped.length > 0 ? { skipped: uniqueSkipped(report.skipped) } : {}),
5064
+ ...(report.conflicts.length > 0 ? { conflicts: report.conflicts } : {}),
5065
+ ...(report.warnings.length > 0 ? { warnings: report.warnings } : {}),
5066
+ };
5067
+ await this.db.update(connectedStores).set({ lastReconcileReport: merged, updatedAt: new Date() }).where(and(
5068
+ eq(connectedStores.organizationId, orgId),
5069
+ eq(connectedStores.id, storeId),
5070
+ ));
5071
+ }
5072
+ return Ok(outcome);
5073
+ }
5074
+
5075
+ private async applyChannelEvent(
5076
+ orgId: string,
5077
+ store: ConnectedStore,
5078
+ connector: ChannelConnector | undefined,
5079
+ event: ChannelEvent,
5080
+ actor: Actor,
5081
+ report: ChannelEventReport,
5082
+ ): Promise<PluginResult<{ data?: ChannelComplianceData; redacted?: number }>> {
5083
+ const storeId = store.id;
5084
+ switch (event.kind) {
5085
+ case "product.changed": {
5086
+ // A webhook is a notification, not a snapshot. The products are read fresh and converged
5087
+ // exactly as an import page would be, creating one this store has never mapped.
5088
+ if (!connector?.fetchCatalogItems) return PluginErr(`Connector "${store.provider}" cannot re-read products, so a product change cannot be applied.`, "CONNECTOR_CANNOT_REREAD");
5089
+ const read = await connector.fetchCatalogItems(store as ChannelStore, event.externalIds);
5090
+ if (!read.ok) return PluginErr(read.error.message, read.error.code);
5091
+ const found = new Set(read.value.map((item) => item.externalId));
5092
+ for (const externalId of event.externalIds.filter((id) => !found.has(id))) {
5093
+ // Gone between the delivery and the read: the same as a delete.
5094
+ const archived = await this.archiveMappedProduct(orgId, storeId, externalId, actor);
5095
+ if (!archived.ok) return archived;
5096
+ report.skipped.push(...archived.value);
5097
+ }
5098
+ if (read.value.length === 0) return Ok({});
5099
+ const converged = await this.convergeCatalogPage(orgId, storeId, read.value, actor);
4972
5100
  if (!converged.ok) return converged;
4973
5101
  const [failure] = converged.value.failures;
4974
5102
  if (failure) return PluginErr(`Product ${failure.externalId} could not be converged: ${failure.error}`, "CONVERGENCE_FAILED");
4975
- warnings = [...converged.value.warnings];
5103
+ report.skipped.push(...converged.value.skipped);
5104
+ report.conflicts.push(...converged.value.conflicts);
5105
+ report.warnings.push(...converged.value.warnings);
4976
5106
  if (converged.value.entityIds.length > 0) {
4977
5107
  await this.options.onStoreCatalogChanged?.({ orgId, storeId, entityIds: [...converged.value.entityIds], convergence: converged.value });
4978
5108
  }
5109
+ return Ok({});
4979
5110
  }
4980
- } else if (event.type === "products/update") {
4981
- const productId = String(data.id ?? data.product_id ?? "");
4982
- 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)));
4983
- if (mapping[0]) {
4984
- const converged = await this.convergeCatalogItem(orgId, storeId, mapping[0].entityId, data, actor);
4985
- if (!converged.ok) return converged;
4986
- skipped = converged.value.skipped;
4987
- conflicts = converged.value.conflicts;
4988
- warnings = converged.value.warnings;
4989
- }
4990
- } else if (event.type === "products/delete") {
4991
- const archived = await this.archiveMappedProduct(orgId, storeId, String(data.id ?? data.product_id ?? ""), actor);
4992
- if (!archived.ok) return archived;
4993
- skipped = archived.value;
4994
- } else if (event.type === "inventory_levels/update") {
4995
- const available = Number(data.available ?? data.stock_quantity ?? 0);
4996
- // Shopify names an INVENTORY ITEM, whose id is not the variant id the channel map is keyed by;
4997
- // looked up by it, every Shopify stock webhook found no mapping and was dropped. The variant is
4998
- // resolved through the inventory item id its import recorded; a provider that sends a variant
4999
- // id keeps the plain lookup.
5000
- const inventoryItemId = data.inventory_item_id !== undefined && data.inventory_item_id !== null ? String(data.inventory_item_id) : null;
5001
- const byInventoryItem = inventoryItemId === null ? null : await this.variantForInventoryItem(orgId, storeId, inventoryItemId);
5002
- if (byInventoryItem !== null) {
5003
- // The delivery's `available` is ONE location's count. The variant's stock is the sum the
5004
- // connector reads, so it is read fresh rather than taken from the payload.
5005
- let quantity = available;
5006
- if (connector) {
5007
- const levels = await connector.fetchInventory(store as ChannelStore, [byInventoryItem.externalId]);
5008
- if (!levels.ok) return PluginErr(levels.error.message, levels.error.code);
5009
- const fresh = levels.value.find((entry) => entry.externalId === byInventoryItem.externalId);
5010
- if (fresh) quantity = fresh.available;
5111
+ case "product.deleted": {
5112
+ for (const externalId of event.externalIds) {
5113
+ const archived = await this.archiveMappedProduct(orgId, storeId, externalId, actor);
5114
+ if (!archived.ok) return archived;
5115
+ report.skipped.push(...archived.value);
5011
5116
  }
5012
- await this.setInventoryLevel(byInventoryItem.entityId, byInventoryItem.variantId, quantity, actor);
5013
- } else {
5014
- const externalId = String(data.variation_id ?? data.product_id ?? inventoryItemId ?? "");
5015
- await this.setMappedInventory(orgId, storeId, externalId, available, actor);
5117
+ return Ok({});
5016
5118
  }
5017
- } else if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled" || event.type === "orders/cancelled") {
5018
- const orderId = await this.resolveOrderId(orgId, storeId, data);
5019
- if (orderId) {
5119
+ case "inventory.changed": {
5120
+ for (const level of event.levels) await this.setMappedInventory(orgId, storeId, level.externalId, level.available, actor);
5121
+ return Ok({});
5122
+ }
5123
+ case "order.cancelled":
5124
+ case "order.fulfilled": {
5125
+ const orderId = await this.orderForRemote(orgId, storeId, event.remoteOrderId);
5126
+ if (!orderId) return Ok({});
5020
5127
  const ordersService = this.services.orders as { addNote(orderId: string, input: { body: string }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }>; changeStatus(input: { orderId: string; newStatus: "processing" | "fulfilled" | "partially_fulfilled" | "cancelled"; reason: string }, actor: Actor): Promise<{ ok: boolean }> };
5021
- const note = await ordersService.addNote(orderId, { body: `Channel ${event.type}: ${String(data.id ?? data.order_id ?? "remote order")}.` }, actor);
5128
+ const what = event.kind === "order.cancelled" ? "cancelled" : event.partial ? "partially fulfilled" : "fulfilled";
5129
+ const note = await ordersService.addNote(orderId, { body: `The store ${what} its order ${event.remoteOrderId}.` }, actor);
5022
5130
  if (!note.ok) return PluginErr(note.error?.message ?? "Could not add channel order note.");
5023
- if (event.type === "orders/cancelled") {
5131
+ if (event.kind === "order.cancelled") {
5024
5132
  // The store cancelled: the platform follows, under a reason the cancel hook recognises, so
5025
5133
  // it does not turn round and cancel at the store again. An order already closed, or one the
5026
5134
  // machine cannot cancel (shipped), keeps its status; the note above records the delivery.
@@ -5028,60 +5136,42 @@ export class ChannelConnectorService {
5028
5136
  if (order && !["cancelled", "refunded"].includes(order.status)) {
5029
5137
  await ordersService.changeStatus({ orderId, newStatus: "cancelled", reason: CHANNEL_ORDER_CANCELLED_REASON }, actor);
5030
5138
  }
5139
+ return Ok({});
5031
5140
  }
5032
- if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled") {
5033
- // The parcels first, so whatever the status move announces (a shipped email) can read them.
5034
- const recorded = await this.recordChannelFulfillments(orgId, storeId, orderId, data, actor);
5035
- if (!recorded.ok) return recorded;
5036
- const target = event.type === "orders/fulfilled" ? "fulfilled" : "partially_fulfilled";
5037
- const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
5038
- if (order?.status === "confirmed") await ordersService.changeStatus({ orderId, newStatus: "processing", reason: "channel_order_fulfilled" }, actor);
5039
- const [after] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
5040
- if (after?.status === "processing" || (target === "fulfilled" && after?.status === "partially_fulfilled")) {
5041
- await ordersService.changeStatus({ orderId, newStatus: target, reason: "channel_order_fulfilled" }, actor);
5042
- }
5141
+ // The parcels first, so whatever the status move announces (a shipped email) can read them.
5142
+ const recorded = await this.recordChannelFulfillments(orgId, storeId, orderId, event.shipments, actor);
5143
+ if (!recorded.ok) return recorded;
5144
+ const target = event.partial ? "partially_fulfilled" : "fulfilled";
5145
+ const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
5146
+ if (order?.status === "confirmed") await ordersService.changeStatus({ orderId, newStatus: "processing", reason: "channel_order_fulfilled" }, actor);
5147
+ const [after] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
5148
+ if (after?.status === "processing" || (target === "fulfilled" && after?.status === "partially_fulfilled")) {
5149
+ await ordersService.changeStatus({ orderId, newStatus: target, reason: "channel_order_fulfilled" }, actor);
5043
5150
  }
5151
+ return Ok({});
5044
5152
  }
5045
- } else if (RETURN_WEBHOOK_STATUS[event.type] !== undefined) {
5046
- // The store's answer to a return the marketplace asked for. A return it never asked for is ignored.
5047
- const status = RETURN_WEBHOOK_STATUS[event.type];
5048
- const remoteReturnId = String(data.id ?? "");
5049
- if (status !== undefined && remoteReturnId !== "") {
5050
- await this.db.update(channelReturns).set({ status, updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.storeId, storeId), eq(channelReturns.remoteReturnId, remoteReturnId)));
5153
+ case "refund.created": {
5154
+ const refund = await this.createRefundRequest(orgId, store, event, actor);
5155
+ return refund.ok ? Ok({}) : refund;
5156
+ }
5157
+ case "return.updated": {
5158
+ // The store's answer to a return the marketplace asked for. A return it never asked for is ignored.
5159
+ await this.db.update(channelReturns).set({ status: event.status, updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.storeId, storeId), eq(channelReturns.remoteReturnId, event.remoteReturnId)));
5160
+ return Ok({});
5161
+ }
5162
+ case "connection.revoked": {
5163
+ const disconnected = await this.disconnectStoreSystem(orgId, storeId);
5164
+ return disconnected.ok ? Ok({}) : disconnected;
5165
+ }
5166
+ case "compliance.request": {
5167
+ if (event.request === "customer_data") {
5168
+ const dataRequest = await this.channelCustomerDataRequest(orgId, storeId, event.data);
5169
+ return dataRequest.ok ? Ok({ data: dataRequest.value }) : dataRequest;
5170
+ }
5171
+ const redacted = event.request === "customer_redact" ? await this.redactCustomerData(orgId, storeId, event.data) : await this.redactShopData(orgId, storeId);
5172
+ return redacted.ok ? Ok({ redacted: redacted.value }) : redacted;
5051
5173
  }
5052
- } else if (event.type === "refunds/create") {
5053
- const refund = await this.createRefundRequest(orgId, store, data, actor);
5054
- if (!refund.ok) return refund;
5055
- } else if (event.type === "customers/data_request") {
5056
- const dataRequest = await this.channelCustomerDataRequest(orgId, storeId, data);
5057
- if (!dataRequest.ok) return dataRequest;
5058
- return Ok({ processed: true, data: dataRequest.value });
5059
- } else if (event.type === "customers/redact") {
5060
- const redacted = await this.redactCustomerData(orgId, storeId, data);
5061
- if (!redacted.ok) return redacted;
5062
- return Ok({ processed: true, redacted: redacted.value });
5063
- } else if (event.type === "shop/redact") {
5064
- const redacted = await this.redactShopData(orgId, storeId);
5065
- if (!redacted.ok) return redacted;
5066
- return Ok({ processed: true, redacted: redacted.value });
5067
- } else if (event.type === "app/uninstalled") {
5068
- const disconnected = await this.disconnectStoreSystem(orgId, storeId);
5069
- if (!disconnected.ok) return disconnected;
5070
- return Ok({ processed: true });
5071
- }
5072
- if (skipped.length > 0 || conflicts.length > 0 || warnings.length > 0) {
5073
- const report = {
5074
- ...(store.lastReconcileReport ?? {}),
5075
- ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
5076
- ...(conflicts.length > 0 ? { conflicts } : {}),
5077
- ...(warnings.length > 0 ? { warnings } : {}),
5078
- };
5079
- await this.db.update(connectedStores).set({ lastReconcileReport: report, updatedAt: new Date() }).where(and(
5080
- eq(connectedStores.organizationId, orgId),
5081
- eq(connectedStores.id, storeId),
5082
- ));
5083
5174
  }
5084
- return Ok({ processed: true });
5085
5175
  }
5086
5176
 
5087
5177
  private complianceEmail(data: Record<string, unknown>): string | undefined {
@@ -5138,10 +5228,53 @@ export class ChannelConnectorService {
5138
5228
  return Ok(rows.filter((row) => row.customerData !== null).length);
5139
5229
  }
5140
5230
 
5141
- private async resolveOrderId(orgId: string, storeId: string, data: Record<string, unknown>): Promise<string | undefined> {
5142
- const nestedOrder = data.order && typeof data.order === "object" ? data.order as Record<string, unknown> : undefined;
5143
- // An `orders/*` payload IS the order, so its own `id` names it; a refund names its order in `order_id`.
5144
- const remoteOrderId = String(data.order_id ?? data.orderId ?? nestedOrder?.id ?? data.id ?? "");
5231
+ /**
5232
+ * For a read point (a shopper opening their order): each of the order's store orders not read for
5233
+ * {@link REMOTE_ORDER_REFRESH_MS} is queued for one refresh, so a store-side cancel, shipment or
5234
+ * refund whose delivery never arrived still reaches the platform. No schedule: someone looked.
5235
+ * Answers how many were queued.
5236
+ */
5237
+ async refreshStaleRemoteOrders(orgId: string, orderId: string, jobs: JobsAdapter): Promise<number> {
5238
+ const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
5239
+ if (!order || ["cancelled", "refunded"].includes(order.status)) return 0;
5240
+ const cutoff = new Date(Date.now() - REMOTE_ORDER_REFRESH_MS);
5241
+ // The claim and the window in one statement: two readers at once queue one refresh.
5242
+ const claimed = await this.db.update(channelOrderExports).set({ remoteCheckedAt: new Date() }).where(and(
5243
+ eq(channelOrderExports.organizationId, orgId),
5244
+ eq(channelOrderExports.orderId, orderId),
5245
+ inArray(channelOrderExports.state, ["exported", "confirmed"]),
5246
+ sql`${channelOrderExports.remoteOrderId} is not null`,
5247
+ or(isNull(channelOrderExports.remoteCheckedAt), lte(channelOrderExports.remoteCheckedAt, cutoff)),
5248
+ )).returning({ storeId: channelOrderExports.storeId, remoteOrderId: channelOrderExports.remoteOrderId });
5249
+ let queued = 0;
5250
+ for (const row of claimed) {
5251
+ if (!row.remoteOrderId) continue;
5252
+ const store = await this.getStoreRecord(orgId, row.storeId);
5253
+ if (!store || store.status !== "connected" || !this.connectors.get(store.provider)?.orderEvents) continue;
5254
+ await jobs.enqueue("channel/refresh-order", { orgId, storeId: row.storeId, remoteOrderId: row.remoteOrderId }, { organizationId: orgId, concurrencyKey: `webhook:${row.storeId}`, supersedes: false });
5255
+ queued += 1;
5256
+ }
5257
+ return queued;
5258
+ }
5259
+
5260
+ /** The store's current state of one order we pushed, applied as if its delivery had arrived. */
5261
+ async refreshRemoteOrder(orgId: string, storeId: string, remoteOrderId: string): Promise<PluginResult<{ events: number }>> {
5262
+ const store = await this.getStoreRecord(orgId, storeId);
5263
+ if (!store) return PluginErr("Connected store not found.", "NOT_FOUND");
5264
+ const connector = this.connectors.get(store.provider);
5265
+ if (!connector?.orderEvents) return Ok({ events: 0 });
5266
+ const events = await connector.orderEvents(store as ChannelStore, remoteOrderId);
5267
+ if (!events.ok) return PluginErr(events.error.message, events.error.code);
5268
+ const report: ChannelEventReport = { skipped: [], conflicts: [], warnings: [] };
5269
+ const actor = createSystemActor(orgId);
5270
+ for (const event of events.value) {
5271
+ const applied = await this.applyChannelEvent(orgId, store, connector, event, actor, report);
5272
+ if (!applied.ok) return applied;
5273
+ }
5274
+ return Ok({ events: events.value.length });
5275
+ }
5276
+
5277
+ private async orderForRemote(orgId: string, storeId: string, remoteOrderId: string): Promise<string | undefined> {
5145
5278
  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)));
5146
5279
  return rows[0]?.orderId;
5147
5280
  }
@@ -5167,192 +5300,20 @@ export class ChannelConnectorService {
5167
5300
  await inventory.setAbsolute({ entityId, ...(variantId ? { variantId } : {}), quantity: Math.max(0, Math.floor(quantity)), reason: "Inventory webhook sync" }, actor);
5168
5301
  }
5169
5302
 
5170
- /** This store's mapped variant whose import recorded `metadata.inventoryItemId`; null when none, or when two claim it. */
5171
- private async variantForInventoryItem(orgId: string, storeId: string, inventoryItemId: string): Promise<{ entityId: string; variantId: string; externalId: string } | null> {
5172
- const rows = await this.db.select({ entityId: channelEntityMap.entityId, variantId: channelEntityMap.variantId, externalId: channelEntityMap.externalId }).from(channelEntityMap)
5173
- .innerJoin(variants, eq(variants.id, channelEntityMap.variantId))
5174
- .where(and(
5175
- eq(channelEntityMap.organizationId, orgId),
5176
- eq(channelEntityMap.storeId, storeId),
5177
- eq(channelEntityMap.kind, "variant"),
5178
- sql`${variants.metadata}->>'inventoryItemId' = ${inventoryItemId}`,
5179
- ))
5180
- .limit(2);
5181
- const [only] = rows;
5182
- return rows.length === 1 && only !== undefined && only.variantId !== null ? { entityId: only.entityId, variantId: only.variantId, externalId: only.externalId } : null;
5183
- }
5184
-
5185
- private async convergeCatalogItem(
5186
- orgId: string,
5187
- storeId: string,
5188
- entityId: string,
5189
- data: Record<string, unknown>,
5190
- actor: Actor,
5191
- ): Promise<PluginResult<{ skipped: CatalogFieldSkip[]; conflicts: CatalogFieldConflict[]; warnings: string[] }>> {
5192
- const product = data.product && typeof data.product === "object" ? data.product as Record<string, unknown> : data;
5193
- const remoteMetadata = product.metadata && typeof product.metadata === "object" && !Array.isArray(product.metadata)
5194
- ? product.metadata as Record<string, unknown>
5195
- : {};
5196
- const [mapping] = await this.db.select().from(channelEntityMap).where(and(
5197
- eq(channelEntityMap.organizationId, orgId),
5198
- eq(channelEntityMap.storeId, storeId),
5199
- eq(channelEntityMap.kind, "entity"),
5200
- eq(channelEntityMap.entityId, entityId),
5201
- ));
5202
- const [entity] = await this.db.select().from(sellableEntities).where(and(
5203
- eq(sellableEntities.organizationId, orgId),
5204
- eq(sellableEntities.id, entityId),
5205
- ));
5206
- if (!mapping || !entity) return Ok({ skipped: [], conflicts: [], warnings: [] });
5207
- const [currentAttribute] = await this.db.select().from(sellableAttributes).where(and(
5208
- eq(sellableAttributes.entityId, entityId),
5209
- eq(sellableAttributes.locale, "en"),
5210
- ));
5211
- const title = typeof product.title === "string" ? product.title : currentAttribute?.title ?? entity.slug;
5212
- const description = product.description !== undefined
5213
- ? String(product.description)
5214
- : currentAttribute?.description ?? undefined;
5215
- const status = typeof product.status === "string" && ["draft", "active", "archived", "discontinued"].includes(product.status)
5216
- ? product.status as NonNullable<ChannelCatalogItem["status"]>
5217
- : undefined;
5218
- const customFields = product.customFields && typeof product.customFields === "object" && !Array.isArray(product.customFields)
5219
- ? product.customFields as Record<string, unknown>
5220
- : undefined;
5221
- const images = Array.isArray(product.images)
5222
- ? product.images.flatMap((raw) => {
5223
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
5224
- const image = raw as Record<string, unknown>;
5225
- const role = typeof image.role === "string" ? pushCatalogImageRole(image.role) : undefined;
5226
- const url = typeof image.url === "string" ? image.url : typeof image.src === "string" ? image.src : undefined;
5227
- return role && url ? [{ role, url }] : [];
5228
- })
5229
- : [];
5230
- const remoteItem = {
5231
- externalId: mapping.externalId,
5232
- slug: typeof product.slug === "string" ? product.slug : entity.slug,
5233
- title,
5234
- ...(description !== undefined ? { description } : {}),
5235
- ...(status !== undefined ? { status } : {}),
5236
- attributes: [{ locale: "en", title, ...(description !== undefined ? { description } : {}) }],
5237
- ...(Object.keys(remoteMetadata).length > 0 ? { metadata: remoteMetadata } : {}),
5238
- ...(customFields !== undefined ? { customFields } : {}),
5239
- ...(images.length > 0 ? { images } : {}),
5240
- variants: [],
5241
- } as ChannelCatalogItem & { customFields?: Record<string, unknown> };
5242
- const fieldPaths: FieldPath[] = [];
5243
- if (typeof product.slug === "string") fieldPaths.push("entity.slug");
5244
- if (status !== undefined) fieldPaths.push("entity.status");
5245
- for (const key of Object.keys(remoteMetadata)) {
5246
- const path = `entity.metadata.${key}`;
5247
- if (isValidFieldPath(path)) fieldPaths.push(path);
5248
- }
5249
- if (typeof product.title === "string") fieldPaths.push("attributes.en.title");
5250
- if (product.description !== undefined) fieldPaths.push("attributes.en.description");
5251
- for (const [name, locales] of Object.entries(customFields ?? {})) {
5252
- if (!locales || typeof locales !== "object" || Array.isArray(locales)) continue;
5253
- for (const locale of Object.keys(locales as Record<string, unknown>)) {
5254
- const path = `customFields.${name}.${locale}`;
5255
- if (isValidFieldPath(path)) fieldPaths.push(path);
5256
- }
5257
- }
5258
- for (const image of images) fieldPaths.push(`media.${image.role}`);
5259
- const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
5260
- const seedPaths = fieldPaths.filter((path) => !ownershipBeforeSeed.has(path));
5261
- const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
5262
- if (!seeded.ok) return PluginErr(seeded.error.message);
5263
- for (const path of seedPaths) ownershipBeforeSeed.set(path, "store");
5264
- const owners = ownershipBeforeSeed;
5265
- const remoteHash = hash(product);
5266
- const outboundEcho = this.isOutboundEcho(mapping, remoteItem);
5267
- const shared = await this.detectSharedConflicts(
5268
- entityId, storeId, entity, mapping, remoteItem, owners, fieldPaths, remoteHash,
5269
- outboundEcho ? { certifiedPaths: new Set(mapping.outboundFieldPaths ?? []) } : undefined,
5270
- );
5271
- const persistedConflicts = await this.persistCatalogConflicts(orgId, shared.conflicts, requireUserId(actor));
5272
- if (!persistedConflicts.ok) return persistedConflicts;
5273
- const owned = this.filterOwnedFieldsAtPaths(remoteItem, owners, fieldPaths);
5274
- const heldPaths = [...new Set([...(mapping.heldFieldPaths ?? []), ...shared.paths])];
5275
- // Same revocation as the reconcile path: a newly held path cancels any force
5276
- // left from an earlier resolution, so a webhook-raised conflict cannot be
5277
- // pre-empted by an operator's answer to a previous one.
5278
- const survivingForcedPaths = (mapping.forcedPushFieldPaths ?? []).filter(
5279
- (path) => !heldPaths.includes(path),
5280
- );
5281
- const held = this.filterConflictingFields(owned.writable, heldPaths);
5282
- const blockedPaths = new Set<FieldPath>([
5283
- ...owned.skipped,
5284
- ...heldPaths,
5285
- ...(!fieldPaths.includes("attributes.en.title") ? ["attributes.en.title" as FieldPath] : []),
5286
- ]);
5287
- const skipped = owned.skipped.map((fieldPath) => ({ entityId, fieldPath }));
5288
- const conflicts = shared.conflicts.map(({ platformValue: _platformValue, storeValue: _storeValue, ...conflict }) => conflict);
5289
- const warnings = conflicts.map((conflict) => `Held shared field conflict for entity "${conflict.entityId}", store "${conflict.storeId}", field "${conflict.fieldPath}" (local ${conflict.localValueSummary}, remote ${conflict.remoteValueSummary}).`);
5290
- const writable = held.writable;
5291
- const updateInput: {
5292
- slug?: string;
5293
- metadata?: Record<string, unknown>;
5294
- status?: string;
5295
- isVisible?: boolean;
5296
- } = {};
5297
- if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && typeof writable.slug === "string") {
5298
- const resolved = (await this.resolveStoreSlugs(orgId, storeId, [writable.slug])).get(writable.slug);
5299
- const slug = resolved ? this.slugToKeep(entity.slug, resolved) : writable.slug;
5300
- if (entity.slug !== slug) updateInput.slug = slug;
5301
- }
5302
- if (Object.keys(writable.metadata ?? {}).length > 0) {
5303
- const remoteEntityMetadata = mergeMetadata(entity.metadata, writable.metadata ?? {});
5304
- if (hash(remoteEntityMetadata) !== hash(entity.metadata ?? {})) updateInput.metadata = remoteEntityMetadata;
5305
- }
5306
- if (fieldPaths.includes("entity.status") && ownerAllows(owners, "entity.status") && !blockedPaths.has("entity.status") && typeof writable.status === "string" && writable.status !== entity.status) {
5307
- updateInput.status = writable.status;
5308
- updateInput.isVisible = writable.status === "active";
5309
- }
5310
- if (Object.keys(updateInput).length > 0) {
5311
- const updated = await this.catalog.update(entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
5312
- if (!updated.ok) return PluginErr(updated.error.message);
5313
- }
5314
- const attributes = await this.setCatalogAttributesIfWritable(entityId, writable, actor, blockedPaths, CHANNEL_CONVERGENCE_CTX);
5315
- if (!attributes.ok) return attributes;
5316
- const levels = Array.isArray(product.variants) ? product.variants as Array<Record<string, unknown>> : [];
5317
- for (const variant of levels) {
5318
- const externalId = String(variant.id ?? variant.variation_id ?? "");
5319
- const available = variant.inventory_quantity ?? variant.stock_quantity;
5320
- if (externalId && available !== undefined) await this.setMappedInventory(orgId, storeId, externalId, Number(available), actor);
5321
- }
5322
- const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
5323
- const lastSyncedAt = revisionMarkers.at(-1)?.createdAt ?? mapping.lastSyncedAt;
5324
- await this.db.update(channelEntityMap).set({
5325
- syncHash: remoteHash,
5326
- lastSyncedAt,
5327
- heldFieldPaths: heldPaths,
5328
- forcedPushFieldPaths: survivingForcedPaths,
5329
- }).where(eq(channelEntityMap.id, mapping.id));
5330
- return Ok({ skipped, conflicts, warnings });
5331
- }
5332
-
5333
- private async createRefundRequest(orgId: string, store: ConnectedStore, data: Record<string, unknown>, actor: Actor): Promise<PluginResult<ChannelRefundRequest>> {
5334
- const remoteRefundId = String(data.id ?? data.refund_id ?? "");
5335
- const orderId = await this.resolveOrderId(orgId, store.id, data);
5336
- if (!remoteRefundId || !orderId) return PluginErr("Refund webhook is missing a mapped order or refund id.", "REFUND_MAPPING_MISSING");
5303
+ private async createRefundRequest(orgId: string, store: ConnectedStore, event: Extract<ChannelEvent, { kind: "refund.created" }>, actor: Actor): Promise<PluginResult<ChannelRefundRequest | null>> {
5304
+ const { remoteRefundId } = event;
5305
+ const orderId = await this.orderForRemote(orgId, store.id, event.remoteOrderId);
5306
+ // A refund on an order this store never received from us is the store's own business.
5307
+ if (!orderId) return Ok(null);
5337
5308
  const existing = await this.db.select().from(channelRefundRequests).where(and(eq(channelRefundRequests.storeId, store.id), eq(channelRefundRequests.remoteRefundId, remoteRefundId)));
5338
5309
  if (existing[0]) return Ok(existing[0] as ChannelRefundRequest);
5339
- // Shopify names the refunded lines in `refund_line_items`, each with its order line under
5340
- // `line_item`; other providers send a flat `line_items`. Both reduce to { variant_id, quantity }.
5341
- const lineData = Array.isArray(data.refund_line_items)
5342
- ? data.refund_line_items.map((raw) => {
5343
- const entry = (raw ?? {}) as Record<string, unknown>;
5344
- const orderLine = (entry.line_item ?? {}) as Record<string, unknown>;
5345
- return { variant_id: orderLine.variant_id, product_id: orderLine.product_id, quantity: entry.quantity };
5346
- })
5347
- : Array.isArray(data.line_items) ? data.line_items : Array.isArray(data.lineItems) ? data.lineItems : [];
5348
5310
  const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
5349
5311
  const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id)));
5350
5312
  const refundLines: Array<{ lineItemId: string; quantity: number }> = [];
5351
- let clean = lineData.length > 0;
5352
- for (const raw of lineData) {
5353
- const line = raw as Record<string, unknown>;
5354
- const externalId = String(line.variant_id ?? line.variantId ?? line.product_id ?? "");
5355
- const quantity = Number(line.quantity ?? 0);
5313
+ let clean = event.lines.length > 0;
5314
+ for (const line of event.lines) {
5315
+ const externalId = line.externalVariantId;
5316
+ const quantity = line.quantity;
5356
5317
  const mapping = mappings.find((item) => item.externalId === externalId);
5357
5318
  const orderLine = mapping ? orderLines.find((item) => item.variantId === mapping.variantId || item.entityId === mapping.entityId) : undefined;
5358
5319
  if (!orderLine || !Number.isInteger(quantity) || quantity < 1 || quantity > orderLine.quantity - orderLine.refundedQuantity) clean = false;
@@ -5532,30 +5493,27 @@ export class ChannelConnectorService {
5532
5493
  }
5533
5494
 
5534
5495
  /**
5535
- * One core fulfilment record per store fulfilment the order body carries, keyed on the store's
5536
- * fulfilment id (`metadata.channelFulfillmentId`) so a replay records nothing twice. Each records
5537
- * the lines it shipped, matched by the store's variant id; one whose lines cannot be matched
5538
- * records every line not yet fulfilled. A fulfilment the store cancelled is not a parcel.
5496
+ * One core fulfilment record per store parcel, keyed on the store's parcel id
5497
+ * (`metadata.channelFulfillmentId`) so a replay records nothing twice. Each records the lines it
5498
+ * shipped, matched by the store's variant id; one whose lines cannot be matched (or that names
5499
+ * none) records every line not yet fulfilled.
5539
5500
  */
5540
- private async recordChannelFulfillments(orgId: string, storeId: string, orderId: string, data: Record<string, unknown>, actor: Actor): Promise<PluginResult<number>> {
5541
- const parsed = channelFulfillmentsSchema.safeParse(data.fulfillments ?? []);
5542
- if (!parsed.success) return PluginErr(`Channel order fulfilments did not parse: ${parsed.error.message}`);
5501
+ private async recordChannelFulfillments(orgId: string, storeId: string, orderId: string, shipments: ChannelShipment[], actor: Actor): Promise<PluginResult<number>> {
5543
5502
  const existing = await this.db.select({ metadata: fulfillmentRecords.metadata }).from(fulfillmentRecords).where(eq(fulfillmentRecords.orderId, orderId));
5544
5503
  const recorded = new Set(existing.map((row) => String(row.metadata?.channelFulfillmentId ?? "")));
5545
5504
  const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
5546
5505
  const fulfillment = this.services.fulfillment as { createFulfillment(input: { orderId: string; lineItems: Array<{ orderLineItemId: string; quantity: number }>; carrier?: string; trackingNumber?: string; trackingUrl?: string; status?: string; metadata?: Record<string, unknown> }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }> };
5547
5506
  let created = 0;
5548
- for (const parcel of parsed.data) {
5549
- const parcelId = String(parcel.id);
5550
- if (recorded.has(parcelId) || parcel.status === "cancelled" || parcel.status === "error" || parcel.status === "failure") continue;
5551
- const externalIds = parcel.line_items.flatMap((line) => (line.variant_id == null ? [] : [String(line.variant_id)]));
5507
+ for (const parcel of shipments) {
5508
+ if (recorded.has(parcel.remoteId)) continue;
5509
+ const externalIds = parcel.lines.map((line) => line.externalVariantId);
5552
5510
  const mapped = externalIds.length === 0 ? [] : await this.db
5553
5511
  .select({ externalId: channelEntityMap.externalId, variantId: channelEntityMap.variantId })
5554
5512
  .from(channelEntityMap)
5555
5513
  .where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.externalId, externalIds)));
5556
5514
  const variantFor = new Map(mapped.map((row) => [row.externalId, row.variantId]));
5557
- const matched = parcel.line_items.flatMap((line) => {
5558
- const variantId = line.variant_id == null ? undefined : variantFor.get(String(line.variant_id));
5515
+ const matched = parcel.lines.flatMap((line) => {
5516
+ const variantId = variantFor.get(line.externalVariantId);
5559
5517
  const orderLine = variantId == null ? undefined : lines.find((candidate) => candidate.variantId === variantId);
5560
5518
  return orderLine ? [{ orderLineItemId: orderLine.id, quantity: line.quantity }] : [];
5561
5519
  });
@@ -5564,14 +5522,14 @@ export class ChannelConnectorService {
5564
5522
  const result = await fulfillment.createFulfillment({
5565
5523
  orderId,
5566
5524
  lineItems,
5567
- ...(parcel.tracking_company ? { carrier: parcel.tracking_company } : {}),
5568
- ...(parcel.tracking_number ? { trackingNumber: parcel.tracking_number } : {}),
5569
- ...(parcel.tracking_url ? { trackingUrl: parcel.tracking_url } : {}),
5525
+ ...(parcel.carrier ? { carrier: parcel.carrier } : {}),
5526
+ ...(parcel.trackingNumber ? { trackingNumber: parcel.trackingNumber } : {}),
5527
+ ...(parcel.trackingUrl ? { trackingUrl: parcel.trackingUrl } : {}),
5570
5528
  status: "shipped",
5571
- metadata: { channelFulfillmentId: parcelId, storeId },
5529
+ metadata: { channelFulfillmentId: parcel.remoteId, storeId, ...(parcel.source ? { trackingSource: parcel.source } : {}) },
5572
5530
  }, actor);
5573
5531
  if (!result.ok) return PluginErr(result.error?.message ?? "Could not record the store's fulfilment.");
5574
- recorded.add(parcelId);
5532
+ recorded.add(parcel.remoteId);
5575
5533
  created += 1;
5576
5534
  }
5577
5535
  return Ok(created);