@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/dist/service.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { z } from "zod";
3
- import { CHANNEL_OUT_OF_STOCK, CommerceInvalidTransitionError, CommerceNotFoundError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, linkFieldPaths, removeEntityLinks, writeEntityLinks, } from "@porulle/core";
3
+ import { CHANNEL_CREDENTIALS_REJECTED, CHANNEL_OUT_OF_STOCK, CommerceInvalidTransitionError, CommerceNotFoundError, CommerceValidationError, Ok, PluginErr, createTxContext, createSystemActor, linkFieldPaths, removeEntityLinks, writeEntityLinks, } from "@porulle/core";
4
4
  import { isValidFieldPath, requireUserId } from "@porulle/core";
5
5
  import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
6
6
  import { resolveLiveCredentials, withLiveCredentials } from "./live-credentials.js";
@@ -33,24 +33,11 @@ export const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
33
33
  * The reason a platform order is cancelled under when its STORE cancelled it. The cancel hook skips
34
34
  * cancelling at the store for exactly this reason, so the two directions cannot loop.
35
35
  */
36
+ /** How stale a store order may be before a read point queues a refresh of it. */
37
+ export const REMOTE_ORDER_REFRESH_MS = 5 * 60 * 1000;
38
+ /** How often a merchant's visit may make the plugin call a store to check its health. */
39
+ export const STORE_HEALTH_INTERVAL_MS = 10 * 60 * 1000;
36
40
  export const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
37
- /** What each store return webhook says about the return. */
38
- const RETURN_WEBHOOK_STATUS = {
39
- "returns/approve": "approved",
40
- "returns/decline": "declined",
41
- "returns/close": "closed",
42
- "returns/cancel": "cancelled",
43
- "returns/reopen": "approved",
44
- };
45
- /** The slice of a store order body's `fulfillments` (Shopify's REST spelling) a parcel is read from. */
46
- const channelFulfillmentsSchema = z.array(z.object({
47
- id: z.union([z.string(), z.number()]),
48
- status: z.string().nullish(),
49
- tracking_company: z.string().nullish(),
50
- tracking_number: z.string().nullish(),
51
- tracking_url: z.string().nullish(),
52
- line_items: z.array(z.object({ variant_id: z.union([z.string(), z.number()]).nullish(), quantity: z.number().int().positive() })).default([]),
53
- }));
54
41
  const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
55
42
  const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
56
43
  export function catalogPushRetryDelayMs(attempts) {
@@ -498,6 +485,9 @@ function redactStore(store) {
498
485
  credentials: "[REDACTED]",
499
486
  storeDomain: store.storeDomain,
500
487
  status: store.status,
488
+ statusReason: store.statusReason,
489
+ health: store.health,
490
+ lastEventAt: store.lastEventAt,
501
491
  catalogWriteEnabled: store.catalogWriteEnabled,
502
492
  catalogFieldMapping: store.catalogFieldMapping,
503
493
  catalogCursor: store.catalogCursor,
@@ -1807,6 +1797,11 @@ export class ChannelConnectorService {
1807
1797
  }
1808
1798
  // A shop_domain can map to more than one connected store (reconnect, or the same
1809
1799
  // shop under two orgs). Compliance webhooks must fan out to all of them.
1800
+ /** This organization's store of `provider` at `storeDomain`, whatever its status. */
1801
+ async storeByDomain(orgId, provider, storeDomain) {
1802
+ const [row] = await this.db.select().from(connectedStores).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.provider, provider), eq(connectedStores.storeDomain, storeDomain)));
1803
+ return row ? redactStore(row) : undefined;
1804
+ }
1810
1805
  async getStoresByDomain(shopDomain) {
1811
1806
  const rows = await this.db
1812
1807
  .select()
@@ -2244,7 +2239,23 @@ export class ChannelConnectorService {
2244
2239
  * provider that subscribes per store is registered after commit, at an absolute address; the
2245
2240
  * consumer's follow-on work ({@link AfterStoreConnected}) runs after that.
2246
2241
  */
2242
+ /**
2243
+ * Connects a store and runs its follow-on work (subscribe, first import) before answering. For a
2244
+ * provider whose callback must be answered at once, see {@link saveConnectingStore} and
2245
+ * {@link completeConnect}, which this is the two halves of.
2246
+ */
2247
2247
  async connectStore(orgId, input, actor) {
2248
+ const saved = await this.saveConnectingStore(orgId, input, actor);
2249
+ if (!saved.ok)
2250
+ return saved;
2251
+ return this.completeConnect(orgId, saved.value.id, actor);
2252
+ }
2253
+ /**
2254
+ * Writes the store with its credentials in status `connecting`, bound to the actor's vendor, and
2255
+ * nothing else: no call to the store. Reconnecting a store this organization already has refreshes
2256
+ * its row instead of adding one.
2257
+ */
2258
+ async saveConnectingStore(orgId, input, actor) {
2248
2259
  const connector = this.connectors.get(input.provider);
2249
2260
  if (!connector)
2250
2261
  return PluginErr(`No connector registered for provider "${input.provider}".`, "NOT_FOUND");
@@ -2254,14 +2265,14 @@ export class ChannelConnectorService {
2254
2265
  if (connector.registerWebhooks && !this.options.publicUrl) {
2255
2266
  return PluginErr(`Connector "${input.provider}" subscribes per store and needs the plugin's publicUrl to give it an absolute address.`, "PUBLIC_URL_REQUIRED");
2256
2267
  }
2257
- let store;
2258
2268
  try {
2259
- store = await this.transact(async (tx) => {
2269
+ const store = await this.transact(async (tx) => {
2260
2270
  const [existing] = await tx.select().from(connectedStores).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.provider, input.provider), eq(connectedStores.storeDomain, storeDomain)));
2261
2271
  const rows = existing
2262
2272
  ? await tx.update(connectedStores).set({
2263
2273
  credentials: input.credentials,
2264
- status: "connected",
2274
+ status: "connecting",
2275
+ statusReason: null,
2265
2276
  ...(existing.status !== "connected" ? { catalogWriteEnabled: false } : {}),
2266
2277
  webhookSecret: input.webhookSecret ?? existing.webhookSecret ?? crypto.randomUUID(),
2267
2278
  updatedAt: new Date(),
@@ -2271,6 +2282,7 @@ export class ChannelConnectorService {
2271
2282
  provider: input.provider,
2272
2283
  credentials: input.credentials,
2273
2284
  storeDomain,
2285
+ status: "connecting",
2274
2286
  webhookSecret: input.webhookSecret ?? crypto.randomUUID(),
2275
2287
  }).returning();
2276
2288
  const written = rows[0];
@@ -2279,41 +2291,56 @@ export class ChannelConnectorService {
2279
2291
  await this.options.bindConnectedStore?.({ db: tx, store: written, actor });
2280
2292
  return written;
2281
2293
  });
2294
+ return Ok(redactStore(store));
2282
2295
  }
2283
2296
  catch (error) {
2284
2297
  return PluginErr(error instanceof Error ? error.message : "The store could not be connected.", error instanceof CommerceNotFoundError ? "NOT_FOUND" : "STORE_CONNECTION_REFUSED");
2285
2298
  }
2299
+ }
2300
+ /**
2301
+ * The work after the credentials are saved: subscribe the store to its connector's topics (all or
2302
+ * none — a partial subscription is removed), mark it `connected`, then the host's follow-on work.
2303
+ * A failure leaves the store in `error` with the reason the merchant will read, unless the host
2304
+ * already moved it (it disconnects a store it refuses).
2305
+ */
2306
+ async completeConnect(orgId, storeId, actor) {
2307
+ const store = await this.getStoreRecord(orgId, storeId);
2308
+ if (!store)
2309
+ return PluginErr("Connected store not found.", "NOT_FOUND");
2310
+ const connector = this.connectors.get(store.provider);
2311
+ if (!connector)
2312
+ return PluginErr(`No connector registered for provider "${store.provider}".`, "NOT_FOUND");
2313
+ const fail = async (message, code) => {
2314
+ await this.db.update(connectedStores).set({ statusReason: message, updatedAt: new Date() }).where(eq(connectedStores.id, storeId));
2315
+ await this.db.update(connectedStores).set({ status: "error" }).where(and(eq(connectedStores.id, storeId), inArray(connectedStores.status, ["connecting", "connected"])));
2316
+ return PluginErr(message, code);
2317
+ };
2286
2318
  if (connector.registerWebhooks && this.options.publicUrl) {
2287
- const callbackUrl = new URL(`/api/channels/webhooks/${store.id}`, this.options.publicUrl).toString();
2288
- const registration = await connector.registerWebhooks(store, [
2289
- "products/create",
2290
- "products/update",
2291
- "products/delete",
2292
- "inventory_levels/update",
2293
- "orders/fulfilled",
2294
- "orders/partially_fulfilled",
2295
- "orders/cancelled",
2296
- "refunds/create",
2297
- "returns/approve",
2298
- "returns/decline",
2299
- "returns/close",
2300
- "returns/cancel",
2301
- "app/uninstalled",
2302
- ], callbackUrl);
2319
+ const callbackUrl = this.webhookCallbackUrl(storeId);
2320
+ const registration = await connector.registerWebhooks(store, [...(connector.webhookTopics ?? [])], callbackUrl);
2303
2321
  if (!registration.ok) {
2304
- await this.db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
2305
- return PluginErr(registration.error.message, "CONNECTOR_REGISTRATION_FAILED");
2322
+ await connector.unregisterWebhooks?.(store, callbackUrl);
2323
+ return fail(registration.error.message, registration.error.code === CHANNEL_CREDENTIALS_REJECTED ? CHANNEL_CREDENTIALS_REJECTED : "CONNECTOR_REGISTRATION_FAILED");
2306
2324
  }
2307
2325
  }
2326
+ const [connected] = await this.db.update(connectedStores).set({ status: "connected", statusReason: null, updatedAt: new Date() })
2327
+ .where(and(eq(connectedStores.id, storeId), eq(connectedStores.status, "connecting"))).returning();
2328
+ const current = (connected ?? store);
2308
2329
  if (this.options.afterStoreConnected) {
2309
2330
  try {
2310
- await this.options.afterStoreConnected({ store, actor, connector, services: this.services });
2331
+ await this.options.afterStoreConnected({ store: current, actor, connector, services: this.services });
2311
2332
  }
2312
2333
  catch (error) {
2313
- return PluginErr(error instanceof Error ? error.message : "The store connected but its follow-on work failed.", "AFTER_CONNECT_FAILED");
2334
+ return fail(error instanceof Error ? error.message : "The store connected but its follow-on work failed.", "AFTER_CONNECT_FAILED");
2314
2335
  }
2315
2336
  }
2316
- return Ok(redactStore(store));
2337
+ return Ok(redactStore(current));
2338
+ }
2339
+ /** Where a store that subscribes per store delivers its webhooks: absolute, on this deployment's public origin. */
2340
+ webhookCallbackUrl(storeId) {
2341
+ if (!this.options.publicUrl)
2342
+ throw new Error("The channel plugin has no publicUrl to build a webhook address on.");
2343
+ return new URL(`/api/channels/webhooks/${storeId}`, this.options.publicUrl).toString();
2317
2344
  }
2318
2345
  /** The store with credentials good for a call the host makes itself, e.g. its own Admin API write. */
2319
2346
  async liveStore(orgId, storeId) {
@@ -2356,6 +2383,14 @@ export class ChannelConnectorService {
2356
2383
  return this.disconnectStoreSystem(orgId, id);
2357
2384
  }
2358
2385
  async disconnectStoreSystem(orgId, id, redactDomain = false) {
2386
+ const before = await this.getStoreRecord(orgId, id);
2387
+ const connector = before ? this.connectors.get(before.provider) : undefined;
2388
+ if (before && connector?.unregisterWebhooks && this.options.publicUrl && before.status !== "disconnected") {
2389
+ // Best effort: the store is disconnected here whatever the provider answers.
2390
+ const removed = await connector.unregisterWebhooks(before, this.webhookCallbackUrl(id));
2391
+ if (!removed.ok)
2392
+ console.warn(JSON.stringify({ event: "channel_unregister_webhooks_failed", provider: before.provider, storeId: id, code: removed.error.code, message: removed.error.message }));
2393
+ }
2359
2394
  const rows = await this.db
2360
2395
  .update(connectedStores)
2361
2396
  .set({
@@ -2372,6 +2407,44 @@ export class ChannelConnectorService {
2372
2407
  return PluginErr("Connected store not found.", "NOT_FOUND");
2373
2408
  return Ok(redactStore(store));
2374
2409
  }
2410
+ /**
2411
+ * Checks the store's webhook subscriptions and key and repairs what it can, when a merchant looks:
2412
+ * there is no scheduled check. At most once per {@link STORE_HEALTH_INTERVAL_MS} per store; inside
2413
+ * that window the last result is answered without calling the store.
2414
+ */
2415
+ async checkStoreHealth(orgId, id, context) {
2416
+ const reached = await this.reachableStore(orgId, id, context);
2417
+ if (!reached.ok)
2418
+ return reached;
2419
+ const store = reached.value;
2420
+ const answer = (health, current, cached) => Ok({ ...health, status: current.status, statusReason: current.statusReason, lastEventAt: current.lastEventAt, cached });
2421
+ if (store.health && Date.now() - Date.parse(store.health.checkedAt) < STORE_HEALTH_INTERVAL_MS)
2422
+ return answer(store.health, store, true);
2423
+ const connector = this.connectors.get(store.provider);
2424
+ if (!connector)
2425
+ return PluginErr(`No connector registered for provider "${store.provider}".`, "NOT_FOUND");
2426
+ let health;
2427
+ const checkedAt = new Date().toISOString();
2428
+ if (store.status !== "connected" && store.status !== "error") {
2429
+ health = { checkedAt, webhooks: "not_applicable", repaired: 0, missing: [], keyValid: false };
2430
+ }
2431
+ else if (connector.webhookHealth && this.options.publicUrl) {
2432
+ const checked = await connector.webhookHealth(store, this.webhookCallbackUrl(id));
2433
+ health = checked.ok
2434
+ ? { checkedAt, webhooks: checked.value.healthy ? (checked.value.repaired > 0 ? "repaired" : "ok") : "failing", repaired: checked.value.repaired, missing: checked.value.missing, keyValid: true }
2435
+ : { checkedAt, webhooks: "failing", repaired: 0, missing: [...(connector.webhookTopics ?? [])], keyValid: checked.error.code !== CHANNEL_CREDENTIALS_REJECTED, error: checked.error.message };
2436
+ }
2437
+ else if (connector.fetchStoreProfile) {
2438
+ // Subscribed per app (Shopify): nothing per store to repair, only the grant to check.
2439
+ const profile = await connector.fetchStoreProfile(store);
2440
+ health = { checkedAt, webhooks: "not_applicable", repaired: 0, missing: [], keyValid: profile.ok || profile.error.code !== CHANNEL_CREDENTIALS_REJECTED, ...(profile.ok ? {} : { error: profile.error.message }) };
2441
+ }
2442
+ else {
2443
+ health = { checkedAt, webhooks: "not_applicable", repaired: 0, missing: [], keyValid: true };
2444
+ }
2445
+ const [updated] = await this.db.update(connectedStores).set({ health, updatedAt: new Date() }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, id))).returning();
2446
+ return answer(health, (updated ?? store), false);
2447
+ }
2375
2448
  async getStore(orgId, id, context) {
2376
2449
  const store = await this.reachableStore(orgId, id, context);
2377
2450
  return store.ok ? Ok(redactStore(store.value)) : store;
@@ -2482,6 +2555,8 @@ export class ChannelConnectorService {
2482
2555
  const items = rawItems.map(withDistinctVariantSkus);
2483
2556
  const failures = [];
2484
2557
  const warnings = [];
2558
+ const skipped = [];
2559
+ const conflicts = [];
2485
2560
  const entityByExternalId = new Map();
2486
2561
  let unchanged = 0;
2487
2562
  let updated = 0;
@@ -2520,6 +2595,8 @@ export class ChannelConnectorService {
2520
2595
  return result;
2521
2596
  failures.push(...result.value.failures);
2522
2597
  warnings.push(...result.value.warnings);
2598
+ skipped.push(...result.value.skipped);
2599
+ conflicts.push(...result.value.conflicts);
2523
2600
  const failedIds = new Set(result.value.failures.map((failure) => failure.externalId));
2524
2601
  const survivors = editor.filter((item) => !failedIds.has(item.externalId));
2525
2602
  survivors.forEach((item, index) => {
@@ -2613,6 +2690,8 @@ export class ChannelConnectorService {
2613
2690
  heroesImported: media.heroesImported,
2614
2691
  mediaFailures: media.mediaFailures,
2615
2692
  deferredMedia: media.deferredMedia,
2693
+ skipped,
2694
+ conflicts,
2616
2695
  warnings,
2617
2696
  });
2618
2697
  }
@@ -3817,100 +3896,107 @@ export class ChannelConnectorService {
3817
3896
  }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
3818
3897
  return Ok({ synced, exhausted });
3819
3898
  }
3820
- async handleWebhook(orgId, storeId, event) {
3899
+ /**
3900
+ * One verified store delivery. The store's connector says what it means ({@link ChannelEvent}); this
3901
+ * acts on the meaning and never on a topic or payload field. A delivery the connector does not act
3902
+ * on is logged as unmapped and answered `processed: false`, never reported as applied.
3903
+ */
3904
+ async handleWebhook(orgId, storeId, delivery) {
3821
3905
  const store = await this.getStoreRecord(orgId, storeId);
3822
3906
  if (!store)
3823
3907
  return PluginErr("Connected store not found.", "NOT_FOUND");
3824
- const actor = createSystemActor(orgId);
3825
- const data = event.data;
3826
- let skipped = [];
3827
- let conflicts = [];
3828
- let warnings = [];
3829
3908
  const connector = this.connectors.get(store.provider);
3830
- if ((event.type === "products/create" || event.type === "products/update") && connector?.fetchCatalogItems) {
3831
- // A webhook is a notification, not a snapshot: its payload is the provider's wire spelling and
3832
- // may be stale or out of order. The product is read fresh and converged exactly as an import
3833
- // page would be — creating it when this store has never mapped it.
3834
- const externalId = String(data.id ?? data.product_id ?? "");
3835
- if (!externalId)
3836
- return PluginErr(`A ${event.type} delivery named no product.`, "INVALID_WEBHOOK");
3837
- const read = await connector.fetchCatalogItems(store, [externalId]);
3838
- if (!read.ok)
3839
- return PluginErr(read.error.message, read.error.code);
3840
- const [item] = read.value;
3841
- if (item === undefined) {
3842
- // Gone between the delivery and the read: the same as a delete.
3843
- const archived = await this.archiveMappedProduct(orgId, storeId, externalId, actor);
3844
- if (!archived.ok)
3845
- return archived;
3846
- skipped = archived.value;
3847
- }
3848
- else {
3849
- const converged = await this.convergeCatalogPage(orgId, storeId, [item], actor);
3909
+ const decoded = connector?.decodeWebhook ? await connector.decodeWebhook(store, delivery) : Ok([]);
3910
+ if (!decoded.ok)
3911
+ return PluginErr(decoded.error.message, decoded.error.code);
3912
+ await this.db.update(connectedStores).set({ lastEventAt: new Date() }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
3913
+ if (decoded.value.length === 0) {
3914
+ console.warn(JSON.stringify({ event: "channel_webhook_unmapped", provider: store.provider, storeId, topic: delivery.type, deliveryId: delivery.id }));
3915
+ return Ok({ processed: false });
3916
+ }
3917
+ const actor = createSystemActor(orgId);
3918
+ const outcome = { processed: true };
3919
+ const report = { skipped: [], conflicts: [], warnings: [] };
3920
+ for (const event of decoded.value) {
3921
+ const applied = await this.applyChannelEvent(orgId, store, connector, event, actor, report);
3922
+ if (!applied.ok)
3923
+ return applied;
3924
+ if (applied.value.data)
3925
+ outcome.data = applied.value.data;
3926
+ if (applied.value.redacted !== undefined)
3927
+ outcome.redacted = applied.value.redacted;
3928
+ }
3929
+ if (report.skipped.length > 0 || report.conflicts.length > 0 || report.warnings.length > 0) {
3930
+ const merged = {
3931
+ ...(store.lastReconcileReport ?? {}),
3932
+ ...(report.skipped.length > 0 ? { skipped: uniqueSkipped(report.skipped) } : {}),
3933
+ ...(report.conflicts.length > 0 ? { conflicts: report.conflicts } : {}),
3934
+ ...(report.warnings.length > 0 ? { warnings: report.warnings } : {}),
3935
+ };
3936
+ await this.db.update(connectedStores).set({ lastReconcileReport: merged, updatedAt: new Date() }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
3937
+ }
3938
+ return Ok(outcome);
3939
+ }
3940
+ async applyChannelEvent(orgId, store, connector, event, actor, report) {
3941
+ const storeId = store.id;
3942
+ switch (event.kind) {
3943
+ case "product.changed": {
3944
+ // A webhook is a notification, not a snapshot. The products are read fresh and converged
3945
+ // exactly as an import page would be, creating one this store has never mapped.
3946
+ if (!connector?.fetchCatalogItems)
3947
+ return PluginErr(`Connector "${store.provider}" cannot re-read products, so a product change cannot be applied.`, "CONNECTOR_CANNOT_REREAD");
3948
+ const read = await connector.fetchCatalogItems(store, event.externalIds);
3949
+ if (!read.ok)
3950
+ return PluginErr(read.error.message, read.error.code);
3951
+ const found = new Set(read.value.map((item) => item.externalId));
3952
+ for (const externalId of event.externalIds.filter((id) => !found.has(id))) {
3953
+ // Gone between the delivery and the read: the same as a delete.
3954
+ const archived = await this.archiveMappedProduct(orgId, storeId, externalId, actor);
3955
+ if (!archived.ok)
3956
+ return archived;
3957
+ report.skipped.push(...archived.value);
3958
+ }
3959
+ if (read.value.length === 0)
3960
+ return Ok({});
3961
+ const converged = await this.convergeCatalogPage(orgId, storeId, read.value, actor);
3850
3962
  if (!converged.ok)
3851
3963
  return converged;
3852
3964
  const [failure] = converged.value.failures;
3853
3965
  if (failure)
3854
3966
  return PluginErr(`Product ${failure.externalId} could not be converged: ${failure.error}`, "CONVERGENCE_FAILED");
3855
- warnings = [...converged.value.warnings];
3967
+ report.skipped.push(...converged.value.skipped);
3968
+ report.conflicts.push(...converged.value.conflicts);
3969
+ report.warnings.push(...converged.value.warnings);
3856
3970
  if (converged.value.entityIds.length > 0) {
3857
3971
  await this.options.onStoreCatalogChanged?.({ orgId, storeId, entityIds: [...converged.value.entityIds], convergence: converged.value });
3858
3972
  }
3973
+ return Ok({});
3859
3974
  }
3860
- }
3861
- else if (event.type === "products/update") {
3862
- const productId = String(data.id ?? data.product_id ?? "");
3863
- 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)));
3864
- if (mapping[0]) {
3865
- const converged = await this.convergeCatalogItem(orgId, storeId, mapping[0].entityId, data, actor);
3866
- if (!converged.ok)
3867
- return converged;
3868
- skipped = converged.value.skipped;
3869
- conflicts = converged.value.conflicts;
3870
- warnings = converged.value.warnings;
3871
- }
3872
- }
3873
- else if (event.type === "products/delete") {
3874
- const archived = await this.archiveMappedProduct(orgId, storeId, String(data.id ?? data.product_id ?? ""), actor);
3875
- if (!archived.ok)
3876
- return archived;
3877
- skipped = archived.value;
3878
- }
3879
- else if (event.type === "inventory_levels/update") {
3880
- const available = Number(data.available ?? data.stock_quantity ?? 0);
3881
- // Shopify names an INVENTORY ITEM, whose id is not the variant id the channel map is keyed by;
3882
- // looked up by it, every Shopify stock webhook found no mapping and was dropped. The variant is
3883
- // resolved through the inventory item id its import recorded; a provider that sends a variant
3884
- // id keeps the plain lookup.
3885
- const inventoryItemId = data.inventory_item_id !== undefined && data.inventory_item_id !== null ? String(data.inventory_item_id) : null;
3886
- const byInventoryItem = inventoryItemId === null ? null : await this.variantForInventoryItem(orgId, storeId, inventoryItemId);
3887
- if (byInventoryItem !== null) {
3888
- // The delivery's `available` is ONE location's count. The variant's stock is the sum the
3889
- // connector reads, so it is read fresh rather than taken from the payload.
3890
- let quantity = available;
3891
- if (connector) {
3892
- const levels = await connector.fetchInventory(store, [byInventoryItem.externalId]);
3893
- if (!levels.ok)
3894
- return PluginErr(levels.error.message, levels.error.code);
3895
- const fresh = levels.value.find((entry) => entry.externalId === byInventoryItem.externalId);
3896
- if (fresh)
3897
- quantity = fresh.available;
3975
+ case "product.deleted": {
3976
+ for (const externalId of event.externalIds) {
3977
+ const archived = await this.archiveMappedProduct(orgId, storeId, externalId, actor);
3978
+ if (!archived.ok)
3979
+ return archived;
3980
+ report.skipped.push(...archived.value);
3898
3981
  }
3899
- await this.setInventoryLevel(byInventoryItem.entityId, byInventoryItem.variantId, quantity, actor);
3982
+ return Ok({});
3900
3983
  }
3901
- else {
3902
- const externalId = String(data.variation_id ?? data.product_id ?? inventoryItemId ?? "");
3903
- await this.setMappedInventory(orgId, storeId, externalId, available, actor);
3984
+ case "inventory.changed": {
3985
+ for (const level of event.levels)
3986
+ await this.setMappedInventory(orgId, storeId, level.externalId, level.available, actor);
3987
+ return Ok({});
3904
3988
  }
3905
- }
3906
- else if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled" || event.type === "orders/cancelled") {
3907
- const orderId = await this.resolveOrderId(orgId, storeId, data);
3908
- if (orderId) {
3989
+ case "order.cancelled":
3990
+ case "order.fulfilled": {
3991
+ const orderId = await this.orderForRemote(orgId, storeId, event.remoteOrderId);
3992
+ if (!orderId)
3993
+ return Ok({});
3909
3994
  const ordersService = this.services.orders;
3910
- const note = await ordersService.addNote(orderId, { body: `Channel ${event.type}: ${String(data.id ?? data.order_id ?? "remote order")}.` }, actor);
3995
+ const what = event.kind === "order.cancelled" ? "cancelled" : event.partial ? "partially fulfilled" : "fulfilled";
3996
+ const note = await ordersService.addNote(orderId, { body: `The store ${what} its order ${event.remoteOrderId}.` }, actor);
3911
3997
  if (!note.ok)
3912
3998
  return PluginErr(note.error?.message ?? "Could not add channel order note.");
3913
- if (event.type === "orders/cancelled") {
3999
+ if (event.kind === "order.cancelled") {
3914
4000
  // The store cancelled: the platform follows, under a reason the cancel hook recognises, so
3915
4001
  // it does not turn round and cancel at the store again. An order already closed, or one the
3916
4002
  // machine cannot cancel (shipped), keeps its status; the note above records the delivery.
@@ -3918,70 +4004,44 @@ export class ChannelConnectorService {
3918
4004
  if (order && !["cancelled", "refunded"].includes(order.status)) {
3919
4005
  await ordersService.changeStatus({ orderId, newStatus: "cancelled", reason: CHANNEL_ORDER_CANCELLED_REASON }, actor);
3920
4006
  }
4007
+ return Ok({});
3921
4008
  }
3922
- if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled") {
3923
- // The parcels first, so whatever the status move announces (a shipped email) can read them.
3924
- const recorded = await this.recordChannelFulfillments(orgId, storeId, orderId, data, actor);
3925
- if (!recorded.ok)
3926
- return recorded;
3927
- const target = event.type === "orders/fulfilled" ? "fulfilled" : "partially_fulfilled";
3928
- const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
3929
- if (order?.status === "confirmed")
3930
- await ordersService.changeStatus({ orderId, newStatus: "processing", reason: "channel_order_fulfilled" }, actor);
3931
- const [after] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
3932
- if (after?.status === "processing" || (target === "fulfilled" && after?.status === "partially_fulfilled")) {
3933
- await ordersService.changeStatus({ orderId, newStatus: target, reason: "channel_order_fulfilled" }, actor);
3934
- }
4009
+ // The parcels first, so whatever the status move announces (a shipped email) can read them.
4010
+ const recorded = await this.recordChannelFulfillments(orgId, storeId, orderId, event.shipments, actor);
4011
+ if (!recorded.ok)
4012
+ return recorded;
4013
+ const target = event.partial ? "partially_fulfilled" : "fulfilled";
4014
+ const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
4015
+ if (order?.status === "confirmed")
4016
+ await ordersService.changeStatus({ orderId, newStatus: "processing", reason: "channel_order_fulfilled" }, actor);
4017
+ const [after] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
4018
+ if (after?.status === "processing" || (target === "fulfilled" && after?.status === "partially_fulfilled")) {
4019
+ await ordersService.changeStatus({ orderId, newStatus: target, reason: "channel_order_fulfilled" }, actor);
3935
4020
  }
4021
+ return Ok({});
3936
4022
  }
3937
- }
3938
- else if (RETURN_WEBHOOK_STATUS[event.type] !== undefined) {
3939
- // The store's answer to a return the marketplace asked for. A return it never asked for is ignored.
3940
- const status = RETURN_WEBHOOK_STATUS[event.type];
3941
- const remoteReturnId = String(data.id ?? "");
3942
- if (status !== undefined && remoteReturnId !== "") {
3943
- await this.db.update(channelReturns).set({ status, updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.storeId, storeId), eq(channelReturns.remoteReturnId, remoteReturnId)));
4023
+ case "refund.created": {
4024
+ const refund = await this.createRefundRequest(orgId, store, event, actor);
4025
+ return refund.ok ? Ok({}) : refund;
4026
+ }
4027
+ case "return.updated": {
4028
+ // The store's answer to a return the marketplace asked for. A return it never asked for is ignored.
4029
+ 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)));
4030
+ return Ok({});
4031
+ }
4032
+ case "connection.revoked": {
4033
+ const disconnected = await this.disconnectStoreSystem(orgId, storeId);
4034
+ return disconnected.ok ? Ok({}) : disconnected;
4035
+ }
4036
+ case "compliance.request": {
4037
+ if (event.request === "customer_data") {
4038
+ const dataRequest = await this.channelCustomerDataRequest(orgId, storeId, event.data);
4039
+ return dataRequest.ok ? Ok({ data: dataRequest.value }) : dataRequest;
4040
+ }
4041
+ const redacted = event.request === "customer_redact" ? await this.redactCustomerData(orgId, storeId, event.data) : await this.redactShopData(orgId, storeId);
4042
+ return redacted.ok ? Ok({ redacted: redacted.value }) : redacted;
3944
4043
  }
3945
4044
  }
3946
- else if (event.type === "refunds/create") {
3947
- const refund = await this.createRefundRequest(orgId, store, data, actor);
3948
- if (!refund.ok)
3949
- return refund;
3950
- }
3951
- else if (event.type === "customers/data_request") {
3952
- const dataRequest = await this.channelCustomerDataRequest(orgId, storeId, data);
3953
- if (!dataRequest.ok)
3954
- return dataRequest;
3955
- return Ok({ processed: true, data: dataRequest.value });
3956
- }
3957
- else if (event.type === "customers/redact") {
3958
- const redacted = await this.redactCustomerData(orgId, storeId, data);
3959
- if (!redacted.ok)
3960
- return redacted;
3961
- return Ok({ processed: true, redacted: redacted.value });
3962
- }
3963
- else if (event.type === "shop/redact") {
3964
- const redacted = await this.redactShopData(orgId, storeId);
3965
- if (!redacted.ok)
3966
- return redacted;
3967
- return Ok({ processed: true, redacted: redacted.value });
3968
- }
3969
- else if (event.type === "app/uninstalled") {
3970
- const disconnected = await this.disconnectStoreSystem(orgId, storeId);
3971
- if (!disconnected.ok)
3972
- return disconnected;
3973
- return Ok({ processed: true });
3974
- }
3975
- if (skipped.length > 0 || conflicts.length > 0 || warnings.length > 0) {
3976
- const report = {
3977
- ...(store.lastReconcileReport ?? {}),
3978
- ...(skipped.length > 0 ? { skipped: uniqueSkipped(skipped) } : {}),
3979
- ...(conflicts.length > 0 ? { conflicts } : {}),
3980
- ...(warnings.length > 0 ? { warnings } : {}),
3981
- };
3982
- await this.db.update(connectedStores).set({ lastReconcileReport: report, updatedAt: new Date() }).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.id, storeId)));
3983
- }
3984
- return Ok({ processed: true });
3985
4045
  }
3986
4046
  complianceEmail(data) {
3987
4047
  const customer = data.customer && typeof data.customer === "object" ? data.customer : undefined;
@@ -4024,10 +4084,52 @@ export class ChannelConnectorService {
4024
4084
  return PluginErr(disconnected.error, disconnected.code);
4025
4085
  return Ok(rows.filter((row) => row.customerData !== null).length);
4026
4086
  }
4027
- async resolveOrderId(orgId, storeId, data) {
4028
- const nestedOrder = data.order && typeof data.order === "object" ? data.order : undefined;
4029
- // An `orders/*` payload IS the order, so its own `id` names it; a refund names its order in `order_id`.
4030
- const remoteOrderId = String(data.order_id ?? data.orderId ?? nestedOrder?.id ?? data.id ?? "");
4087
+ /**
4088
+ * For a read point (a shopper opening their order): each of the order's store orders not read for
4089
+ * {@link REMOTE_ORDER_REFRESH_MS} is queued for one refresh, so a store-side cancel, shipment or
4090
+ * refund whose delivery never arrived still reaches the platform. No schedule: someone looked.
4091
+ * Answers how many were queued.
4092
+ */
4093
+ async refreshStaleRemoteOrders(orgId, orderId, jobs) {
4094
+ const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
4095
+ if (!order || ["cancelled", "refunded"].includes(order.status))
4096
+ return 0;
4097
+ const cutoff = new Date(Date.now() - REMOTE_ORDER_REFRESH_MS);
4098
+ // The claim and the window in one statement: two readers at once queue one refresh.
4099
+ const claimed = await this.db.update(channelOrderExports).set({ remoteCheckedAt: new Date() }).where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.orderId, orderId), inArray(channelOrderExports.state, ["exported", "confirmed"]), sql `${channelOrderExports.remoteOrderId} is not null`, or(isNull(channelOrderExports.remoteCheckedAt), lte(channelOrderExports.remoteCheckedAt, cutoff)))).returning({ storeId: channelOrderExports.storeId, remoteOrderId: channelOrderExports.remoteOrderId });
4100
+ let queued = 0;
4101
+ for (const row of claimed) {
4102
+ if (!row.remoteOrderId)
4103
+ continue;
4104
+ const store = await this.getStoreRecord(orgId, row.storeId);
4105
+ if (!store || store.status !== "connected" || !this.connectors.get(store.provider)?.orderEvents)
4106
+ continue;
4107
+ await jobs.enqueue("channel/refresh-order", { orgId, storeId: row.storeId, remoteOrderId: row.remoteOrderId }, { organizationId: orgId, concurrencyKey: `webhook:${row.storeId}`, supersedes: false });
4108
+ queued += 1;
4109
+ }
4110
+ return queued;
4111
+ }
4112
+ /** The store's current state of one order we pushed, applied as if its delivery had arrived. */
4113
+ async refreshRemoteOrder(orgId, storeId, remoteOrderId) {
4114
+ const store = await this.getStoreRecord(orgId, storeId);
4115
+ if (!store)
4116
+ return PluginErr("Connected store not found.", "NOT_FOUND");
4117
+ const connector = this.connectors.get(store.provider);
4118
+ if (!connector?.orderEvents)
4119
+ return Ok({ events: 0 });
4120
+ const events = await connector.orderEvents(store, remoteOrderId);
4121
+ if (!events.ok)
4122
+ return PluginErr(events.error.message, events.error.code);
4123
+ const report = { skipped: [], conflicts: [], warnings: [] };
4124
+ const actor = createSystemActor(orgId);
4125
+ for (const event of events.value) {
4126
+ const applied = await this.applyChannelEvent(orgId, store, connector, event, actor, report);
4127
+ if (!applied.ok)
4128
+ return applied;
4129
+ }
4130
+ return Ok({ events: events.value.length });
4131
+ }
4132
+ async orderForRemote(orgId, storeId, remoteOrderId) {
4031
4133
  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)));
4032
4134
  return rows[0]?.orderId;
4033
4135
  }
@@ -4052,178 +4154,22 @@ export class ChannelConnectorService {
4052
4154
  const inventory = this.services.inventory;
4053
4155
  await inventory.setAbsolute({ entityId, ...(variantId ? { variantId } : {}), quantity: Math.max(0, Math.floor(quantity)), reason: "Inventory webhook sync" }, actor);
4054
4156
  }
4055
- /** This store's mapped variant whose import recorded `metadata.inventoryItemId`; null when none, or when two claim it. */
4056
- async variantForInventoryItem(orgId, storeId, inventoryItemId) {
4057
- const rows = await this.db.select({ entityId: channelEntityMap.entityId, variantId: channelEntityMap.variantId, externalId: channelEntityMap.externalId }).from(channelEntityMap)
4058
- .innerJoin(variants, eq(variants.id, channelEntityMap.variantId))
4059
- .where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), sql `${variants.metadata}->>'inventoryItemId' = ${inventoryItemId}`))
4060
- .limit(2);
4061
- const [only] = rows;
4062
- return rows.length === 1 && only !== undefined && only.variantId !== null ? { entityId: only.entityId, variantId: only.variantId, externalId: only.externalId } : null;
4063
- }
4064
- async convergeCatalogItem(orgId, storeId, entityId, data, actor) {
4065
- const product = data.product && typeof data.product === "object" ? data.product : data;
4066
- const remoteMetadata = product.metadata && typeof product.metadata === "object" && !Array.isArray(product.metadata)
4067
- ? product.metadata
4068
- : {};
4069
- const [mapping] = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "entity"), eq(channelEntityMap.entityId, entityId)));
4070
- const [entity] = await this.db.select().from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), eq(sellableEntities.id, entityId)));
4071
- if (!mapping || !entity)
4072
- return Ok({ skipped: [], conflicts: [], warnings: [] });
4073
- const [currentAttribute] = await this.db.select().from(sellableAttributes).where(and(eq(sellableAttributes.entityId, entityId), eq(sellableAttributes.locale, "en")));
4074
- const title = typeof product.title === "string" ? product.title : currentAttribute?.title ?? entity.slug;
4075
- const description = product.description !== undefined
4076
- ? String(product.description)
4077
- : currentAttribute?.description ?? undefined;
4078
- const status = typeof product.status === "string" && ["draft", "active", "archived", "discontinued"].includes(product.status)
4079
- ? product.status
4080
- : undefined;
4081
- const customFields = product.customFields && typeof product.customFields === "object" && !Array.isArray(product.customFields)
4082
- ? product.customFields
4083
- : undefined;
4084
- const images = Array.isArray(product.images)
4085
- ? product.images.flatMap((raw) => {
4086
- if (!raw || typeof raw !== "object" || Array.isArray(raw))
4087
- return [];
4088
- const image = raw;
4089
- const role = typeof image.role === "string" ? pushCatalogImageRole(image.role) : undefined;
4090
- const url = typeof image.url === "string" ? image.url : typeof image.src === "string" ? image.src : undefined;
4091
- return role && url ? [{ role, url }] : [];
4092
- })
4093
- : [];
4094
- const remoteItem = {
4095
- externalId: mapping.externalId,
4096
- slug: typeof product.slug === "string" ? product.slug : entity.slug,
4097
- title,
4098
- ...(description !== undefined ? { description } : {}),
4099
- ...(status !== undefined ? { status } : {}),
4100
- attributes: [{ locale: "en", title, ...(description !== undefined ? { description } : {}) }],
4101
- ...(Object.keys(remoteMetadata).length > 0 ? { metadata: remoteMetadata } : {}),
4102
- ...(customFields !== undefined ? { customFields } : {}),
4103
- ...(images.length > 0 ? { images } : {}),
4104
- variants: [],
4105
- };
4106
- const fieldPaths = [];
4107
- if (typeof product.slug === "string")
4108
- fieldPaths.push("entity.slug");
4109
- if (status !== undefined)
4110
- fieldPaths.push("entity.status");
4111
- for (const key of Object.keys(remoteMetadata)) {
4112
- const path = `entity.metadata.${key}`;
4113
- if (isValidFieldPath(path))
4114
- fieldPaths.push(path);
4115
- }
4116
- if (typeof product.title === "string")
4117
- fieldPaths.push("attributes.en.title");
4118
- if (product.description !== undefined)
4119
- fieldPaths.push("attributes.en.description");
4120
- for (const [name, locales] of Object.entries(customFields ?? {})) {
4121
- if (!locales || typeof locales !== "object" || Array.isArray(locales))
4122
- continue;
4123
- for (const locale of Object.keys(locales)) {
4124
- const path = `customFields.${name}.${locale}`;
4125
- if (isValidFieldPath(path))
4126
- fieldPaths.push(path);
4127
- }
4128
- }
4129
- for (const image of images)
4130
- fieldPaths.push(`media.${image.role}`);
4131
- const ownershipBeforeSeed = await this.catalog.resolveFieldOwners(entityId, storeId);
4132
- const seedPaths = fieldPaths.filter((path) => !ownershipBeforeSeed.has(path));
4133
- const seeded = await this.catalog.seedImportedFieldOwnership(entityId, storeId, seedPaths);
4134
- if (!seeded.ok)
4135
- return PluginErr(seeded.error.message);
4136
- for (const path of seedPaths)
4137
- ownershipBeforeSeed.set(path, "store");
4138
- const owners = ownershipBeforeSeed;
4139
- const remoteHash = hash(product);
4140
- const outboundEcho = this.isOutboundEcho(mapping, remoteItem);
4141
- const shared = await this.detectSharedConflicts(entityId, storeId, entity, mapping, remoteItem, owners, fieldPaths, remoteHash, outboundEcho ? { certifiedPaths: new Set(mapping.outboundFieldPaths ?? []) } : undefined);
4142
- const persistedConflicts = await this.persistCatalogConflicts(orgId, shared.conflicts, requireUserId(actor));
4143
- if (!persistedConflicts.ok)
4144
- return persistedConflicts;
4145
- const owned = this.filterOwnedFieldsAtPaths(remoteItem, owners, fieldPaths);
4146
- const heldPaths = [...new Set([...(mapping.heldFieldPaths ?? []), ...shared.paths])];
4147
- // Same revocation as the reconcile path: a newly held path cancels any force
4148
- // left from an earlier resolution, so a webhook-raised conflict cannot be
4149
- // pre-empted by an operator's answer to a previous one.
4150
- const survivingForcedPaths = (mapping.forcedPushFieldPaths ?? []).filter((path) => !heldPaths.includes(path));
4151
- const held = this.filterConflictingFields(owned.writable, heldPaths);
4152
- const blockedPaths = new Set([
4153
- ...owned.skipped,
4154
- ...heldPaths,
4155
- ...(!fieldPaths.includes("attributes.en.title") ? ["attributes.en.title"] : []),
4156
- ]);
4157
- const skipped = owned.skipped.map((fieldPath) => ({ entityId, fieldPath }));
4158
- const conflicts = shared.conflicts.map(({ platformValue: _platformValue, storeValue: _storeValue, ...conflict }) => conflict);
4159
- 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}).`);
4160
- const writable = held.writable;
4161
- const updateInput = {};
4162
- if (fieldPaths.includes("entity.slug") && ownerAllows(owners, "entity.slug") && !blockedPaths.has("entity.slug") && typeof writable.slug === "string") {
4163
- const resolved = (await this.resolveStoreSlugs(orgId, storeId, [writable.slug])).get(writable.slug);
4164
- const slug = resolved ? this.slugToKeep(entity.slug, resolved) : writable.slug;
4165
- if (entity.slug !== slug)
4166
- updateInput.slug = slug;
4167
- }
4168
- if (Object.keys(writable.metadata ?? {}).length > 0) {
4169
- const remoteEntityMetadata = mergeMetadata(entity.metadata, writable.metadata ?? {});
4170
- if (hash(remoteEntityMetadata) !== hash(entity.metadata ?? {}))
4171
- updateInput.metadata = remoteEntityMetadata;
4172
- }
4173
- if (fieldPaths.includes("entity.status") && ownerAllows(owners, "entity.status") && !blockedPaths.has("entity.status") && typeof writable.status === "string" && writable.status !== entity.status) {
4174
- updateInput.status = writable.status;
4175
- updateInput.isVisible = writable.status === "active";
4176
- }
4177
- if (Object.keys(updateInput).length > 0) {
4178
- const updated = await this.catalog.update(entityId, updateInput, actor, CHANNEL_CONVERGENCE_CTX);
4179
- if (!updated.ok)
4180
- return PluginErr(updated.error.message);
4181
- }
4182
- const attributes = await this.setCatalogAttributesIfWritable(entityId, writable, actor, blockedPaths, CHANNEL_CONVERGENCE_CTX);
4183
- if (!attributes.ok)
4184
- return attributes;
4185
- const levels = Array.isArray(product.variants) ? product.variants : [];
4186
- for (const variant of levels) {
4187
- const externalId = String(variant.id ?? variant.variation_id ?? "");
4188
- const available = variant.inventory_quantity ?? variant.stock_quantity;
4189
- if (externalId && available !== undefined)
4190
- await this.setMappedInventory(orgId, storeId, externalId, Number(available), actor);
4191
- }
4192
- const revisionMarkers = await this.catalog.repository.findRevisionMarkers(entityId);
4193
- const lastSyncedAt = revisionMarkers.at(-1)?.createdAt ?? mapping.lastSyncedAt;
4194
- await this.db.update(channelEntityMap).set({
4195
- syncHash: remoteHash,
4196
- lastSyncedAt,
4197
- heldFieldPaths: heldPaths,
4198
- forcedPushFieldPaths: survivingForcedPaths,
4199
- }).where(eq(channelEntityMap.id, mapping.id));
4200
- return Ok({ skipped, conflicts, warnings });
4201
- }
4202
- async createRefundRequest(orgId, store, data, actor) {
4203
- const remoteRefundId = String(data.id ?? data.refund_id ?? "");
4204
- const orderId = await this.resolveOrderId(orgId, store.id, data);
4205
- if (!remoteRefundId || !orderId)
4206
- return PluginErr("Refund webhook is missing a mapped order or refund id.", "REFUND_MAPPING_MISSING");
4157
+ async createRefundRequest(orgId, store, event, actor) {
4158
+ const { remoteRefundId } = event;
4159
+ const orderId = await this.orderForRemote(orgId, store.id, event.remoteOrderId);
4160
+ // A refund on an order this store never received from us is the store's own business.
4161
+ if (!orderId)
4162
+ return Ok(null);
4207
4163
  const existing = await this.db.select().from(channelRefundRequests).where(and(eq(channelRefundRequests.storeId, store.id), eq(channelRefundRequests.remoteRefundId, remoteRefundId)));
4208
4164
  if (existing[0])
4209
4165
  return Ok(existing[0]);
4210
- // Shopify names the refunded lines in `refund_line_items`, each with its order line under
4211
- // `line_item`; other providers send a flat `line_items`. Both reduce to { variant_id, quantity }.
4212
- const lineData = Array.isArray(data.refund_line_items)
4213
- ? data.refund_line_items.map((raw) => {
4214
- const entry = (raw ?? {});
4215
- const orderLine = (entry.line_item ?? {});
4216
- return { variant_id: orderLine.variant_id, product_id: orderLine.product_id, quantity: entry.quantity };
4217
- })
4218
- : Array.isArray(data.line_items) ? data.line_items : Array.isArray(data.lineItems) ? data.lineItems : [];
4219
4166
  const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
4220
4167
  const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id)));
4221
4168
  const refundLines = [];
4222
- let clean = lineData.length > 0;
4223
- for (const raw of lineData) {
4224
- const line = raw;
4225
- const externalId = String(line.variant_id ?? line.variantId ?? line.product_id ?? "");
4226
- const quantity = Number(line.quantity ?? 0);
4169
+ let clean = event.lines.length > 0;
4170
+ for (const line of event.lines) {
4171
+ const externalId = line.externalVariantId;
4172
+ const quantity = line.quantity;
4227
4173
  const mapping = mappings.find((item) => item.externalId === externalId);
4228
4174
  const orderLine = mapping ? orderLines.find((item) => item.variantId === mapping.variantId || item.entityId === mapping.entityId) : undefined;
4229
4175
  if (!orderLine || !Number.isInteger(quantity) || quantity < 1 || quantity > orderLine.quantity - orderLine.refundedQuantity)
@@ -4381,32 +4327,28 @@ export class ChannelConnectorService {
4381
4327
  return Ok(cancelled);
4382
4328
  }
4383
4329
  /**
4384
- * One core fulfilment record per store fulfilment the order body carries, keyed on the store's
4385
- * fulfilment id (`metadata.channelFulfillmentId`) so a replay records nothing twice. Each records
4386
- * the lines it shipped, matched by the store's variant id; one whose lines cannot be matched
4387
- * records every line not yet fulfilled. A fulfilment the store cancelled is not a parcel.
4330
+ * One core fulfilment record per store parcel, keyed on the store's parcel id
4331
+ * (`metadata.channelFulfillmentId`) so a replay records nothing twice. Each records the lines it
4332
+ * shipped, matched by the store's variant id; one whose lines cannot be matched (or that names
4333
+ * none) records every line not yet fulfilled.
4388
4334
  */
4389
- async recordChannelFulfillments(orgId, storeId, orderId, data, actor) {
4390
- const parsed = channelFulfillmentsSchema.safeParse(data.fulfillments ?? []);
4391
- if (!parsed.success)
4392
- return PluginErr(`Channel order fulfilments did not parse: ${parsed.error.message}`);
4335
+ async recordChannelFulfillments(orgId, storeId, orderId, shipments, actor) {
4393
4336
  const existing = await this.db.select({ metadata: fulfillmentRecords.metadata }).from(fulfillmentRecords).where(eq(fulfillmentRecords.orderId, orderId));
4394
4337
  const recorded = new Set(existing.map((row) => String(row.metadata?.channelFulfillmentId ?? "")));
4395
4338
  const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
4396
4339
  const fulfillment = this.services.fulfillment;
4397
4340
  let created = 0;
4398
- for (const parcel of parsed.data) {
4399
- const parcelId = String(parcel.id);
4400
- if (recorded.has(parcelId) || parcel.status === "cancelled" || parcel.status === "error" || parcel.status === "failure")
4341
+ for (const parcel of shipments) {
4342
+ if (recorded.has(parcel.remoteId))
4401
4343
  continue;
4402
- const externalIds = parcel.line_items.flatMap((line) => (line.variant_id == null ? [] : [String(line.variant_id)]));
4344
+ const externalIds = parcel.lines.map((line) => line.externalVariantId);
4403
4345
  const mapped = externalIds.length === 0 ? [] : await this.db
4404
4346
  .select({ externalId: channelEntityMap.externalId, variantId: channelEntityMap.variantId })
4405
4347
  .from(channelEntityMap)
4406
4348
  .where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.externalId, externalIds)));
4407
4349
  const variantFor = new Map(mapped.map((row) => [row.externalId, row.variantId]));
4408
- const matched = parcel.line_items.flatMap((line) => {
4409
- const variantId = line.variant_id == null ? undefined : variantFor.get(String(line.variant_id));
4350
+ const matched = parcel.lines.flatMap((line) => {
4351
+ const variantId = variantFor.get(line.externalVariantId);
4410
4352
  const orderLine = variantId == null ? undefined : lines.find((candidate) => candidate.variantId === variantId);
4411
4353
  return orderLine ? [{ orderLineItemId: orderLine.id, quantity: line.quantity }] : [];
4412
4354
  });
@@ -4416,15 +4358,15 @@ export class ChannelConnectorService {
4416
4358
  const result = await fulfillment.createFulfillment({
4417
4359
  orderId,
4418
4360
  lineItems,
4419
- ...(parcel.tracking_company ? { carrier: parcel.tracking_company } : {}),
4420
- ...(parcel.tracking_number ? { trackingNumber: parcel.tracking_number } : {}),
4421
- ...(parcel.tracking_url ? { trackingUrl: parcel.tracking_url } : {}),
4361
+ ...(parcel.carrier ? { carrier: parcel.carrier } : {}),
4362
+ ...(parcel.trackingNumber ? { trackingNumber: parcel.trackingNumber } : {}),
4363
+ ...(parcel.trackingUrl ? { trackingUrl: parcel.trackingUrl } : {}),
4422
4364
  status: "shipped",
4423
- metadata: { channelFulfillmentId: parcelId, storeId },
4365
+ metadata: { channelFulfillmentId: parcel.remoteId, storeId, ...(parcel.source ? { trackingSource: parcel.source } : {}) },
4424
4366
  }, actor);
4425
4367
  if (!result.ok)
4426
4368
  return PluginErr(result.error?.message ?? "Could not record the store's fulfilment.");
4427
- recorded.add(parcelId);
4369
+ recorded.add(parcel.remoteId);
4428
4370
  created += 1;
4429
4371
  }
4430
4372
  return Ok(created);