@porulle/plugin-channel-connector 0.68.4 → 0.70.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/hooks.js CHANGED
@@ -1,7 +1,7 @@
1
- import { resolveOrgIdForCommerce } from "@porulle/core";
1
+ import { CommerceValidationError, resolveOrgIdForCommerce } from "@porulle/core";
2
2
  import { and, eq, inArray } from "@porulle/core/drizzle";
3
3
  import { sellableEntities } from "@porulle/core/schema";
4
- import { ChannelConnectorService, } from "./service.js";
4
+ import { CHANNEL_ORDER_CANCELLED_REASON, ChannelConnectorService, } from "./service.js";
5
5
  import { handleCatalogAfterUpdate, recordUpdateFieldPaths, } from "./catalog-push-trigger.js";
6
6
  /**
7
7
  * The state an order sits in while it waits for a payment gateway. Core owns it
@@ -157,8 +157,54 @@ function pushHooks(mode) {
157
157
  }];
158
158
  }
159
159
  }
160
+ function parseCancelRequest(args) {
161
+ if (!isRecord(args))
162
+ return null;
163
+ const { data } = args;
164
+ if (!isRecord(data))
165
+ return null;
166
+ const { orderId, newStatus, reason } = data;
167
+ if (typeof orderId !== "string" || newStatus !== "cancelled")
168
+ return null;
169
+ return { orderId, reason: typeof reason === "string" ? reason : undefined };
170
+ }
171
+ /** The store's reason, read loosely from the platform's free-text one. */
172
+ function storeCancelReason(reason) {
173
+ if (reason === undefined)
174
+ return "other";
175
+ if (/customer|shopper/i.test(reason))
176
+ return "customer";
177
+ if (/stock|inventory/i.test(reason))
178
+ return "inventory";
179
+ return "other";
180
+ }
181
+ /**
182
+ * Cancel at the store BEFORE the platform cancels, so a store that refuses — it has shipped — blocks
183
+ * the platform's cancel instead of the shopper being refunded for goods on their way. A cancel the
184
+ * store itself started is not sent back to it.
185
+ */
186
+ function cancelAtStoreHook(options) {
187
+ return {
188
+ key: "orders.beforeStatusChange",
189
+ async handler(args) {
190
+ // A before-hook's return value REPLACES the data, so every path hands it back unchanged.
191
+ if (!isRecord(args))
192
+ throw new Error("orders.beforeStatusChange delivered no payload.");
193
+ const { data } = args;
194
+ const request = parseCancelRequest(args);
195
+ if (request === null || request.reason === CHANNEL_ORDER_CANCELLED_REASON || !hasHookContext(args))
196
+ return data;
197
+ const { context } = args;
198
+ const service = new ChannelConnectorService(context.db, context.services, options);
199
+ const cancelled = await service.cancelRemoteOrders(resolveOrgIdForCommerce(context.actor, context.commerceConfig), request.orderId, { reason: storeCancelReason(request.reason), staffNote: `Cancelled on the marketplace${request.reason ? ` (${request.reason})` : ""}.` });
200
+ if (!cancelled.ok)
201
+ throw new CommerceValidationError(`The store would not cancel this order: ${cancelled.error}`);
202
+ return data;
203
+ },
204
+ };
205
+ }
160
206
  export function buildHooks(options) {
161
- return [{
207
+ return [cancelAtStoreHook(options), {
162
208
  key: "checkout.beforePayment",
163
209
  async handler(args) {
164
210
  const { data, context } = args;
package/dist/index.js CHANGED
@@ -240,6 +240,9 @@ export function channelConnectorPlugin(options = {}) {
240
240
  const orgId = String(input.orgId);
241
241
  const storeId = String(input.storeId);
242
242
  const orderId = String(input.orderId);
243
+ // Cancelled before this ran: the store must never receive an order nobody is paying for.
244
+ if (await service.isOrderClosed(orgId, orderId))
245
+ return { output: { state: "skipped", reason: "order closed" } };
243
246
  const existing = await service.createExport(orgId, storeId, orderId);
244
247
  if (!existing.ok)
245
248
  throw new Error(existing.error);
@@ -76,5 +76,6 @@ export function withLiveCredentials(connector, db) {
76
76
  ...(connector.pushCatalog ? { pushCatalog: around(connector.pushCatalog) } : {}),
77
77
  ...(connector.reserve ? { reserve: around(connector.reserve) } : {}),
78
78
  ...(connector.registerWebhooks ? { registerWebhooks: around(connector.registerWebhooks) } : {}),
79
+ ...(connector.cancelOrder ? { cancelOrder: around(connector.cancelOrder) } : {}),
79
80
  };
80
81
  }
package/dist/service.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import type { Actor, ChannelCatalogItem, ChannelConnector, ChannelOrderSlice, ChannelPushCatalogField, ChannelPushCatalogImage, ChannelPushCatalogItem, ChannelPushCatalogItemOutcome, ChannelPushCatalogResult, ChannelStore, PluginDb, PluginResult, PluginTxFn } from "@porulle/core";
2
+ import type { Actor, ChannelCancelOrderInput, ChannelCatalogItem, ChannelConnector, ChannelOrderSlice, ChannelPushCatalogField, ChannelPushCatalogImage, ChannelPushCatalogItem, ChannelPushCatalogItemOutcome, ChannelPushCatalogResult, ChannelStore, PluginDb, PluginResult, PluginTxFn } from "@porulle/core";
3
3
  import type { ChannelCatalogImage } from "@porulle/core";
4
4
  import type { FieldOwner, FieldPath } from "@porulle/core";
5
5
  import type { JobsAdapter } from "@porulle/core";
@@ -22,6 +22,11 @@ export declare const CATALOG_PUSH_MAX_ATTEMPTS = 8;
22
22
  * nine-hour one that discards everything if it fails.
23
23
  */
24
24
  export declare const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
25
+ /**
26
+ * The reason a platform order is cancelled under when its STORE cancelled it. The cancel hook skips
27
+ * cancelling at the store for exactly this reason, so the two directions cannot loop.
28
+ */
29
+ export declare const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
25
30
  export declare function catalogPushRetryDelayMs(attempts: number): number;
26
31
  export interface CatalogPushJobResult extends Record<string, unknown> {
27
32
  noop?: boolean;
@@ -653,6 +658,23 @@ export declare class ChannelConnectorService {
653
658
  private refundLinesForRequest;
654
659
  createExport(orgId: string, storeId: string, orderId: string): Promise<PluginResult<ChannelOrderExport>>;
655
660
  transitionExport(orgId: string, exportId: string, toState: ExportState, changedBy: string, reason?: string, failureKind?: "definitive" | "transient"): Promise<PluginResult<ChannelOrderExport>>;
661
+ /**
662
+ * Cancels this order at every store it was pushed to. Any refusal is returned as the error, so the
663
+ * caller can refuse its own cancel: a store that has shipped must not see the marketplace refund
664
+ * goods already on their way. A store with no connector able to cancel is left to its merchant.
665
+ */
666
+ cancelRemoteOrders(orgId: string, orderId: string, input: ChannelCancelOrderInput): Promise<PluginResult<number>>;
667
+ /**
668
+ * One core fulfilment record per store fulfilment the order body carries, keyed on the store's
669
+ * fulfilment id (`metadata.channelFulfillmentId`) so a replay records nothing twice. Each records
670
+ * the lines it shipped, matched by the store's variant id; one whose lines cannot be matched
671
+ * records every line not yet fulfilled. A fulfilment the store cancelled is not a parcel.
672
+ */
673
+ private recordChannelFulfillments;
674
+ /** Every line with quantity still to ship, for a parcel whose own lines could not be matched. */
675
+ private unfulfilledLines;
676
+ /** A cancelled or refunded order: nothing to push to a store, ever again. */
677
+ isOrderClosed(orgId: string, orderId: string): Promise<boolean>;
656
678
  exportOrder(orgId: string, storeId: string, slice: ChannelOrderSlice, actor: Actor): Promise<PluginResult<ChannelOrderExport>>;
657
679
  buildOrderSlice(orgId: string, storeId: string, orderId: string): Promise<PluginResult<ChannelOrderSlice>>;
658
680
  reapExports(input: {
package/dist/service.js CHANGED
@@ -5,7 +5,7 @@ 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";
7
7
  import { and, desc, eq, inArray, isNull, lte, or, sql } from "@porulle/core/drizzle";
8
- import { brands, categories, customerAddresses, customers, entityBrands, entityCategories, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
8
+ import { brands, categories, customerAddresses, customers, entityBrands, entityCategories, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, fulfillmentLineItems, fulfillmentRecords, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
9
9
  import { planAbsentArchives } from "./deletion-policy.js";
10
10
  import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityLinks, channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
11
11
  import { mergeCatalogFieldMapping, normalizeCatalogFieldMapping, selectCatalogFieldMapping, } from "./catalog-field-mapping.js";
@@ -29,6 +29,20 @@ export const CATALOG_PUSH_MAX_ATTEMPTS = 8;
29
29
  * nine-hour one that discards everything if it fails.
30
30
  */
31
31
  export const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
32
+ /**
33
+ * The reason a platform order is cancelled under when its STORE cancelled it. The cancel hook skips
34
+ * cancelling at the store for exactly this reason, so the two directions cannot loop.
35
+ */
36
+ export const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
37
+ /** The slice of a store order body's `fulfillments` (Shopify's REST spelling) a parcel is read from. */
38
+ const channelFulfillmentsSchema = z.array(z.object({
39
+ id: z.union([z.string(), z.number()]),
40
+ status: z.string().nullish(),
41
+ tracking_company: z.string().nullish(),
42
+ tracking_number: z.string().nullish(),
43
+ tracking_url: z.string().nullish(),
44
+ line_items: z.array(z.object({ variant_id: z.union([z.string(), z.number()]).nullish(), quantity: z.number().int().positive() })).default([]),
45
+ }));
32
46
  const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
33
47
  const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
34
48
  export function catalogPushRetryDelayMs(attempts) {
@@ -2269,6 +2283,7 @@ export class ChannelConnectorService {
2269
2283
  "products/delete",
2270
2284
  "inventory_levels/update",
2271
2285
  "orders/fulfilled",
2286
+ "orders/partially_fulfilled",
2272
2287
  "orders/cancelled",
2273
2288
  "app/uninstalled",
2274
2289
  ], callbackUrl);
@@ -3875,20 +3890,35 @@ export class ChannelConnectorService {
3875
3890
  await this.setMappedInventory(orgId, storeId, externalId, available, actor);
3876
3891
  }
3877
3892
  }
3878
- else if (event.type === "orders/fulfilled" || event.type === "orders/cancelled") {
3893
+ else if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled" || event.type === "orders/cancelled") {
3879
3894
  const orderId = await this.resolveOrderId(orgId, storeId, data);
3880
3895
  if (orderId) {
3881
3896
  const ordersService = this.services.orders;
3882
3897
  const note = await ordersService.addNote(orderId, { body: `Channel ${event.type}: ${String(data.id ?? data.order_id ?? "remote order")}.` }, actor);
3883
3898
  if (!note.ok)
3884
3899
  return PluginErr(note.error?.message ?? "Could not add channel order note.");
3885
- if (event.type === "orders/fulfilled") {
3900
+ if (event.type === "orders/cancelled") {
3901
+ // The store cancelled: the platform follows, under a reason the cancel hook recognises, so
3902
+ // it does not turn round and cancel at the store again. An order already closed, or one the
3903
+ // machine cannot cancel (shipped), keeps its status; the note above records the delivery.
3904
+ const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
3905
+ if (order && !["cancelled", "refunded"].includes(order.status)) {
3906
+ await ordersService.changeStatus({ orderId, newStatus: "cancelled", reason: CHANNEL_ORDER_CANCELLED_REASON }, actor);
3907
+ }
3908
+ }
3909
+ if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled") {
3910
+ // The parcels first, so whatever the status move announces (a shipped email) can read them.
3911
+ const recorded = await this.recordChannelFulfillments(orgId, storeId, orderId, data, actor);
3912
+ if (!recorded.ok)
3913
+ return recorded;
3914
+ const target = event.type === "orders/fulfilled" ? "fulfilled" : "partially_fulfilled";
3886
3915
  const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
3887
3916
  if (order?.status === "confirmed")
3888
3917
  await ordersService.changeStatus({ orderId, newStatus: "processing", reason: "channel_order_fulfilled" }, actor);
3889
3918
  const [after] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
3890
- if (after?.status === "processing")
3891
- await ordersService.changeStatus({ orderId, newStatus: "fulfilled", reason: "channel_order_fulfilled" }, actor);
3919
+ if (after?.status === "processing" || (target === "fulfilled" && after?.status === "partially_fulfilled")) {
3920
+ await ordersService.changeStatus({ orderId, newStatus: target, reason: "channel_order_fulfilled" }, actor);
3921
+ }
3892
3922
  }
3893
3923
  }
3894
3924
  }
@@ -4283,6 +4313,102 @@ export class ChannelConnectorService {
4283
4313
  return Ok(updated);
4284
4314
  });
4285
4315
  }
4316
+ /**
4317
+ * Cancels this order at every store it was pushed to. Any refusal is returned as the error, so the
4318
+ * caller can refuse its own cancel: a store that has shipped must not see the marketplace refund
4319
+ * goods already on their way. A store with no connector able to cancel is left to its merchant.
4320
+ */
4321
+ async cancelRemoteOrders(orgId, orderId, input) {
4322
+ const exports = await this.db
4323
+ .select({ storeId: channelOrderExports.storeId, remoteOrderId: channelOrderExports.remoteOrderId })
4324
+ .from(channelOrderExports)
4325
+ .where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.orderId, orderId)));
4326
+ let cancelled = 0;
4327
+ for (const exported of exports) {
4328
+ if (exported.remoteOrderId === null)
4329
+ continue;
4330
+ const store = await this.getStoreRecord(orgId, exported.storeId);
4331
+ if (!store || store.status !== "connected")
4332
+ continue;
4333
+ // ponytail: a provider without cancelOrder is skipped silently; refuse instead if one ever ships without it.
4334
+ const connector = this.connectors.get(store.provider);
4335
+ if (!connector?.cancelOrder)
4336
+ continue;
4337
+ const result = await connector.cancelOrder(store, exported.remoteOrderId, input);
4338
+ if (!result.ok)
4339
+ return PluginErr(result.error.message, result.error.code);
4340
+ cancelled += 1;
4341
+ }
4342
+ return Ok(cancelled);
4343
+ }
4344
+ /**
4345
+ * One core fulfilment record per store fulfilment the order body carries, keyed on the store's
4346
+ * fulfilment id (`metadata.channelFulfillmentId`) so a replay records nothing twice. Each records
4347
+ * the lines it shipped, matched by the store's variant id; one whose lines cannot be matched
4348
+ * records every line not yet fulfilled. A fulfilment the store cancelled is not a parcel.
4349
+ */
4350
+ async recordChannelFulfillments(orgId, storeId, orderId, data, actor) {
4351
+ const parsed = channelFulfillmentsSchema.safeParse(data.fulfillments ?? []);
4352
+ if (!parsed.success)
4353
+ return PluginErr(`Channel order fulfilments did not parse: ${parsed.error.message}`);
4354
+ const existing = await this.db.select({ metadata: fulfillmentRecords.metadata }).from(fulfillmentRecords).where(eq(fulfillmentRecords.orderId, orderId));
4355
+ const recorded = new Set(existing.map((row) => String(row.metadata?.channelFulfillmentId ?? "")));
4356
+ const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
4357
+ const fulfillment = this.services.fulfillment;
4358
+ let created = 0;
4359
+ for (const parcel of parsed.data) {
4360
+ const parcelId = String(parcel.id);
4361
+ if (recorded.has(parcelId) || parcel.status === "cancelled" || parcel.status === "error" || parcel.status === "failure")
4362
+ continue;
4363
+ const externalIds = parcel.line_items.flatMap((line) => (line.variant_id == null ? [] : [String(line.variant_id)]));
4364
+ const mapped = externalIds.length === 0 ? [] : await this.db
4365
+ .select({ externalId: channelEntityMap.externalId, variantId: channelEntityMap.variantId })
4366
+ .from(channelEntityMap)
4367
+ .where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.externalId, externalIds)));
4368
+ const variantFor = new Map(mapped.map((row) => [row.externalId, row.variantId]));
4369
+ const matched = parcel.line_items.flatMap((line) => {
4370
+ const variantId = line.variant_id == null ? undefined : variantFor.get(String(line.variant_id));
4371
+ const orderLine = variantId == null ? undefined : lines.find((candidate) => candidate.variantId === variantId);
4372
+ return orderLine ? [{ orderLineItemId: orderLine.id, quantity: line.quantity }] : [];
4373
+ });
4374
+ const lineItems = matched.length > 0 ? matched : await this.unfulfilledLines(lines);
4375
+ if (lineItems.length === 0)
4376
+ continue;
4377
+ const result = await fulfillment.createFulfillment({
4378
+ orderId,
4379
+ lineItems,
4380
+ ...(parcel.tracking_company ? { carrier: parcel.tracking_company } : {}),
4381
+ ...(parcel.tracking_number ? { trackingNumber: parcel.tracking_number } : {}),
4382
+ ...(parcel.tracking_url ? { trackingUrl: parcel.tracking_url } : {}),
4383
+ status: "shipped",
4384
+ metadata: { channelFulfillmentId: parcelId, storeId },
4385
+ }, actor);
4386
+ if (!result.ok)
4387
+ return PluginErr(result.error?.message ?? "Could not record the store's fulfilment.");
4388
+ recorded.add(parcelId);
4389
+ created += 1;
4390
+ }
4391
+ return Ok(created);
4392
+ }
4393
+ /** Every line with quantity still to ship, for a parcel whose own lines could not be matched. */
4394
+ async unfulfilledLines(lines) {
4395
+ const ids = lines.map((line) => line.id);
4396
+ const shipped = ids.length === 0 ? [] : await this.db
4397
+ .select({ lineId: fulfillmentLineItems.orderLineItemId, quantity: sql `coalesce(sum(${fulfillmentLineItems.quantity}), 0)::int` })
4398
+ .from(fulfillmentLineItems)
4399
+ .where(inArray(fulfillmentLineItems.orderLineItemId, ids))
4400
+ .groupBy(fulfillmentLineItems.orderLineItemId);
4401
+ const shippedBy = new Map(shipped.map((row) => [row.lineId, row.quantity]));
4402
+ return lines.flatMap((line) => {
4403
+ const remaining = line.quantity - (shippedBy.get(line.id) ?? 0);
4404
+ return remaining > 0 ? [{ orderLineItemId: line.id, quantity: remaining }] : [];
4405
+ });
4406
+ }
4407
+ /** A cancelled or refunded order: nothing to push to a store, ever again. */
4408
+ async isOrderClosed(orgId, orderId) {
4409
+ const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
4410
+ return order?.status === "cancelled" || order?.status === "refunded";
4411
+ }
4286
4412
  async exportOrder(orgId, storeId, slice, actor) {
4287
4413
  const store = await this.getStoreRecord(orgId, storeId);
4288
4414
  if (!store || store.status !== "connected") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.68.4",
3
+ "version": "0.70.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -22,15 +22,15 @@
22
22
  "dependencies": {
23
23
  "@hono/zod-openapi": "^1.2.2",
24
24
  "hono": "^4.12.5",
25
- "@porulle/core": "0.68.4"
25
+ "@porulle/core": "0.70.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^24.5.2",
29
29
  "eslint": "^9.39.1",
30
30
  "typescript": "5.9.2",
31
31
  "vitest": "^3.2.4",
32
- "@porulle/typescript-config": "0.1.0",
33
- "@porulle/eslint-config": "0.1.0"
32
+ "@porulle/eslint-config": "0.1.0",
33
+ "@porulle/typescript-config": "0.1.0"
34
34
  },
35
35
  "publishConfig": {
36
36
  "access": "public"
package/src/hooks.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { resolveOrgIdForCommerce } from "@porulle/core";
1
+ import { CommerceValidationError, resolveOrgIdForCommerce } from "@porulle/core";
2
2
  import type {
3
3
  Actor,
4
+ ChannelCancelReason,
4
5
  CommerceConfig,
5
6
  HookContext,
6
7
  PluginHookRegistration,
@@ -8,6 +9,7 @@ import type {
8
9
  import { and, eq, inArray } from "@porulle/core/drizzle";
9
10
  import { sellableEntities } from "@porulle/core/schema";
10
11
  import {
12
+ CHANNEL_ORDER_CANCELLED_REASON,
11
13
  ChannelConnectorService,
12
14
  type ChannelConnectorPluginOptions,
13
15
  type ChannelPushTrigger,
@@ -202,8 +204,58 @@ function pushHooks(mode: ChannelPushTrigger): PluginHookRegistration[] {
202
204
  }
203
205
  }
204
206
 
207
+ /** The cancel a status change asks for, on `orders.beforeStatusChange`. */
208
+ interface CancelRequest {
209
+ orderId: string;
210
+ reason: string | undefined;
211
+ }
212
+
213
+ function parseCancelRequest(args: unknown): CancelRequest | null {
214
+ if (!isRecord(args)) return null;
215
+ const { data } = args;
216
+ if (!isRecord(data)) return null;
217
+ const { orderId, newStatus, reason } = data;
218
+ if (typeof orderId !== "string" || newStatus !== "cancelled") return null;
219
+ return { orderId, reason: typeof reason === "string" ? reason : undefined };
220
+ }
221
+
222
+ /** The store's reason, read loosely from the platform's free-text one. */
223
+ function storeCancelReason(reason: string | undefined): ChannelCancelReason {
224
+ if (reason === undefined) return "other";
225
+ if (/customer|shopper/i.test(reason)) return "customer";
226
+ if (/stock|inventory/i.test(reason)) return "inventory";
227
+ return "other";
228
+ }
229
+
230
+ /**
231
+ * Cancel at the store BEFORE the platform cancels, so a store that refuses — it has shipped — blocks
232
+ * the platform's cancel instead of the shopper being refunded for goods on their way. A cancel the
233
+ * store itself started is not sent back to it.
234
+ */
235
+ function cancelAtStoreHook(options: ChannelConnectorPluginOptions): PluginHookRegistration {
236
+ return {
237
+ key: "orders.beforeStatusChange",
238
+ async handler(args: unknown) {
239
+ // A before-hook's return value REPLACES the data, so every path hands it back unchanged.
240
+ if (!isRecord(args)) throw new Error("orders.beforeStatusChange delivered no payload.");
241
+ const { data } = args;
242
+ const request = parseCancelRequest(args);
243
+ if (request === null || request.reason === CHANNEL_ORDER_CANCELLED_REASON || !hasHookContext(args)) return data;
244
+ const { context } = args;
245
+ const service = new ChannelConnectorService(context.db, context.services, options);
246
+ const cancelled = await service.cancelRemoteOrders(
247
+ resolveOrgIdForCommerce(context.actor, context.commerceConfig),
248
+ request.orderId,
249
+ { reason: storeCancelReason(request.reason), staffNote: `Cancelled on the marketplace${request.reason ? ` (${request.reason})` : ""}.` },
250
+ );
251
+ if (!cancelled.ok) throw new CommerceValidationError(`The store would not cancel this order: ${cancelled.error}`);
252
+ return data;
253
+ },
254
+ };
255
+ }
256
+
205
257
  export function buildHooks(options: ChannelConnectorPluginOptions): PluginHookRegistration[] {
206
- return [{
258
+ return [cancelAtStoreHook(options), {
207
259
  key: "checkout.beforePayment",
208
260
  async handler(args: unknown) {
209
261
  const { data, context } = args as {
package/src/index.ts CHANGED
@@ -409,6 +409,8 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
409
409
  const orgId = String(input.orgId);
410
410
  const storeId = String(input.storeId);
411
411
  const orderId = String(input.orderId);
412
+ // Cancelled before this ran: the store must never receive an order nobody is paying for.
413
+ if (await service.isOrderClosed(orgId, orderId)) return { output: { state: "skipped", reason: "order closed" } };
412
414
  const existing = await service.createExport(orgId, storeId, orderId);
413
415
  if (!existing.ok) throw new Error(existing.error);
414
416
  const slice = await service.buildOrderSlice(orgId, storeId, orderId);
@@ -73,5 +73,6 @@ export function withLiveCredentials(connector: ChannelConnector, db: PluginDb):
73
73
  ...(connector.pushCatalog ? { pushCatalog: around(connector.pushCatalog) } : {}),
74
74
  ...(connector.reserve ? { reserve: around(connector.reserve) } : {}),
75
75
  ...(connector.registerWebhooks ? { registerWebhooks: around(connector.registerWebhooks) } : {}),
76
+ ...(connector.cancelOrder ? { cancelOrder: around(connector.cancelOrder) } : {}),
76
77
  };
77
78
  }
package/src/service.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from "@porulle/core";
15
15
  import type {
16
16
  Actor,
17
+ ChannelCancelOrderInput,
17
18
  EntityLinkRows,
18
19
  ChannelCatalogItem,
19
20
  ChannelConnector,
@@ -55,6 +56,8 @@ import {
55
56
  mediaAssets,
56
57
  optionTypes,
57
58
  optionValues,
59
+ fulfillmentLineItems,
60
+ fulfillmentRecords,
58
61
  orderLineItems,
59
62
  orders,
60
63
  prices,
@@ -123,6 +126,22 @@ export const CATALOG_PUSH_MAX_ATTEMPTS = 8;
123
126
  */
124
127
  export const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
125
128
 
129
+ /**
130
+ * The reason a platform order is cancelled under when its STORE cancelled it. The cancel hook skips
131
+ * cancelling at the store for exactly this reason, so the two directions cannot loop.
132
+ */
133
+ export const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
134
+
135
+ /** The slice of a store order body's `fulfillments` (Shopify's REST spelling) a parcel is read from. */
136
+ const channelFulfillmentsSchema = z.array(z.object({
137
+ id: z.union([z.string(), z.number()]),
138
+ status: z.string().nullish(),
139
+ tracking_company: z.string().nullish(),
140
+ tracking_number: z.string().nullish(),
141
+ tracking_url: z.string().nullish(),
142
+ line_items: z.array(z.object({ variant_id: z.union([z.string(), z.number()]).nullish(), quantity: z.number().int().positive() })).default([]),
143
+ }));
144
+
126
145
  const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
127
146
  const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
128
147
 
@@ -3182,6 +3201,7 @@ export class ChannelConnectorService {
3182
3201
  "products/delete",
3183
3202
  "inventory_levels/update",
3184
3203
  "orders/fulfilled",
3204
+ "orders/partially_fulfilled",
3185
3205
  "orders/cancelled",
3186
3206
  "app/uninstalled",
3187
3207
  ], callbackUrl);
@@ -4978,17 +4998,32 @@ export class ChannelConnectorService {
4978
4998
  const externalId = String(data.variation_id ?? data.product_id ?? inventoryItemId ?? "");
4979
4999
  await this.setMappedInventory(orgId, storeId, externalId, available, actor);
4980
5000
  }
4981
- } else if (event.type === "orders/fulfilled" || event.type === "orders/cancelled") {
5001
+ } else if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled" || event.type === "orders/cancelled") {
4982
5002
  const orderId = await this.resolveOrderId(orgId, storeId, data);
4983
5003
  if (orderId) {
4984
- 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"; reason: string }, actor: Actor): Promise<{ ok: boolean }> };
5004
+ 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 }> };
4985
5005
  const note = await ordersService.addNote(orderId, { body: `Channel ${event.type}: ${String(data.id ?? data.order_id ?? "remote order")}.` }, actor);
4986
5006
  if (!note.ok) return PluginErr(note.error?.message ?? "Could not add channel order note.");
4987
- if (event.type === "orders/fulfilled") {
5007
+ if (event.type === "orders/cancelled") {
5008
+ // The store cancelled: the platform follows, under a reason the cancel hook recognises, so
5009
+ // it does not turn round and cancel at the store again. An order already closed, or one the
5010
+ // machine cannot cancel (shipped), keeps its status; the note above records the delivery.
5011
+ const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
5012
+ if (order && !["cancelled", "refunded"].includes(order.status)) {
5013
+ await ordersService.changeStatus({ orderId, newStatus: "cancelled", reason: CHANNEL_ORDER_CANCELLED_REASON }, actor);
5014
+ }
5015
+ }
5016
+ if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled") {
5017
+ // The parcels first, so whatever the status move announces (a shipped email) can read them.
5018
+ const recorded = await this.recordChannelFulfillments(orgId, storeId, orderId, data, actor);
5019
+ if (!recorded.ok) return recorded;
5020
+ const target = event.type === "orders/fulfilled" ? "fulfilled" : "partially_fulfilled";
4988
5021
  const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
4989
5022
  if (order?.status === "confirmed") await ordersService.changeStatus({ orderId, newStatus: "processing", reason: "channel_order_fulfilled" }, actor);
4990
5023
  const [after] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
4991
- if (after?.status === "processing") await ordersService.changeStatus({ orderId, newStatus: "fulfilled", reason: "channel_order_fulfilled" }, actor);
5024
+ if (after?.status === "processing" || (target === "fulfilled" && after?.status === "partially_fulfilled")) {
5025
+ await ordersService.changeStatus({ orderId, newStatus: target, reason: "channel_order_fulfilled" }, actor);
5026
+ }
4992
5027
  }
4993
5028
  }
4994
5029
  } else if (event.type === "refunds/create") {
@@ -5429,6 +5464,98 @@ export class ChannelConnectorService {
5429
5464
  });
5430
5465
  }
5431
5466
 
5467
+ /**
5468
+ * Cancels this order at every store it was pushed to. Any refusal is returned as the error, so the
5469
+ * caller can refuse its own cancel: a store that has shipped must not see the marketplace refund
5470
+ * goods already on their way. A store with no connector able to cancel is left to its merchant.
5471
+ */
5472
+ async cancelRemoteOrders(orgId: string, orderId: string, input: ChannelCancelOrderInput): Promise<PluginResult<number>> {
5473
+ const exports = await this.db
5474
+ .select({ storeId: channelOrderExports.storeId, remoteOrderId: channelOrderExports.remoteOrderId })
5475
+ .from(channelOrderExports)
5476
+ .where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.orderId, orderId)));
5477
+ let cancelled = 0;
5478
+ for (const exported of exports) {
5479
+ if (exported.remoteOrderId === null) continue;
5480
+ const store = await this.getStoreRecord(orgId, exported.storeId);
5481
+ if (!store || store.status !== "connected") continue;
5482
+ // ponytail: a provider without cancelOrder is skipped silently; refuse instead if one ever ships without it.
5483
+ const connector = this.connectors.get(store.provider);
5484
+ if (!connector?.cancelOrder) continue;
5485
+ const result = await connector.cancelOrder(store as ChannelStore, exported.remoteOrderId, input);
5486
+ if (!result.ok) return PluginErr(result.error.message, result.error.code);
5487
+ cancelled += 1;
5488
+ }
5489
+ return Ok(cancelled);
5490
+ }
5491
+
5492
+ /**
5493
+ * One core fulfilment record per store fulfilment the order body carries, keyed on the store's
5494
+ * fulfilment id (`metadata.channelFulfillmentId`) so a replay records nothing twice. Each records
5495
+ * the lines it shipped, matched by the store's variant id; one whose lines cannot be matched
5496
+ * records every line not yet fulfilled. A fulfilment the store cancelled is not a parcel.
5497
+ */
5498
+ private async recordChannelFulfillments(orgId: string, storeId: string, orderId: string, data: Record<string, unknown>, actor: Actor): Promise<PluginResult<number>> {
5499
+ const parsed = channelFulfillmentsSchema.safeParse(data.fulfillments ?? []);
5500
+ if (!parsed.success) return PluginErr(`Channel order fulfilments did not parse: ${parsed.error.message}`);
5501
+ const existing = await this.db.select({ metadata: fulfillmentRecords.metadata }).from(fulfillmentRecords).where(eq(fulfillmentRecords.orderId, orderId));
5502
+ const recorded = new Set(existing.map((row) => String(row.metadata?.channelFulfillmentId ?? "")));
5503
+ const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
5504
+ 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 } }> };
5505
+ let created = 0;
5506
+ for (const parcel of parsed.data) {
5507
+ const parcelId = String(parcel.id);
5508
+ if (recorded.has(parcelId) || parcel.status === "cancelled" || parcel.status === "error" || parcel.status === "failure") continue;
5509
+ const externalIds = parcel.line_items.flatMap((line) => (line.variant_id == null ? [] : [String(line.variant_id)]));
5510
+ const mapped = externalIds.length === 0 ? [] : await this.db
5511
+ .select({ externalId: channelEntityMap.externalId, variantId: channelEntityMap.variantId })
5512
+ .from(channelEntityMap)
5513
+ .where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.externalId, externalIds)));
5514
+ const variantFor = new Map(mapped.map((row) => [row.externalId, row.variantId]));
5515
+ const matched = parcel.line_items.flatMap((line) => {
5516
+ const variantId = line.variant_id == null ? undefined : variantFor.get(String(line.variant_id));
5517
+ const orderLine = variantId == null ? undefined : lines.find((candidate) => candidate.variantId === variantId);
5518
+ return orderLine ? [{ orderLineItemId: orderLine.id, quantity: line.quantity }] : [];
5519
+ });
5520
+ const lineItems = matched.length > 0 ? matched : await this.unfulfilledLines(lines);
5521
+ if (lineItems.length === 0) continue;
5522
+ const result = await fulfillment.createFulfillment({
5523
+ orderId,
5524
+ lineItems,
5525
+ ...(parcel.tracking_company ? { carrier: parcel.tracking_company } : {}),
5526
+ ...(parcel.tracking_number ? { trackingNumber: parcel.tracking_number } : {}),
5527
+ ...(parcel.tracking_url ? { trackingUrl: parcel.tracking_url } : {}),
5528
+ status: "shipped",
5529
+ metadata: { channelFulfillmentId: parcelId, storeId },
5530
+ }, actor);
5531
+ if (!result.ok) return PluginErr(result.error?.message ?? "Could not record the store's fulfilment.");
5532
+ recorded.add(parcelId);
5533
+ created += 1;
5534
+ }
5535
+ return Ok(created);
5536
+ }
5537
+
5538
+ /** Every line with quantity still to ship, for a parcel whose own lines could not be matched. */
5539
+ private async unfulfilledLines(lines: Array<{ id: string; quantity: number }>): Promise<Array<{ orderLineItemId: string; quantity: number }>> {
5540
+ const ids = lines.map((line) => line.id);
5541
+ const shipped = ids.length === 0 ? [] : await this.db
5542
+ .select({ lineId: fulfillmentLineItems.orderLineItemId, quantity: sql<number>`coalesce(sum(${fulfillmentLineItems.quantity}), 0)::int` })
5543
+ .from(fulfillmentLineItems)
5544
+ .where(inArray(fulfillmentLineItems.orderLineItemId, ids))
5545
+ .groupBy(fulfillmentLineItems.orderLineItemId);
5546
+ const shippedBy = new Map(shipped.map((row) => [row.lineId, row.quantity]));
5547
+ return lines.flatMap((line) => {
5548
+ const remaining = line.quantity - (shippedBy.get(line.id) ?? 0);
5549
+ return remaining > 0 ? [{ orderLineItemId: line.id, quantity: remaining }] : [];
5550
+ });
5551
+ }
5552
+
5553
+ /** A cancelled or refunded order: nothing to push to a store, ever again. */
5554
+ async isOrderClosed(orgId: string, orderId: string): Promise<boolean> {
5555
+ const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
5556
+ return order?.status === "cancelled" || order?.status === "refunded";
5557
+ }
5558
+
5432
5559
  async exportOrder(
5433
5560
  orgId: string,
5434
5561
  storeId: string,