@porulle/plugin-channel-connector 0.68.4 → 0.69.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,14 @@ 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
+ /** A cancelled or refunded order: nothing to push to a store, ever again. */
668
+ isOrderClosed(orgId: string, orderId: string): Promise<boolean>;
656
669
  exportOrder(orgId: string, storeId: string, slice: ChannelOrderSlice, actor: Actor): Promise<PluginResult<ChannelOrderExport>>;
657
670
  buildOrderSlice(orgId: string, storeId: string, orderId: string): Promise<PluginResult<ChannelOrderSlice>>;
658
671
  reapExports(input: {
package/dist/service.js CHANGED
@@ -29,6 +29,11 @@ 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";
32
37
  const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
33
38
  const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
34
39
  export function catalogPushRetryDelayMs(attempts) {
@@ -3882,6 +3887,15 @@ export class ChannelConnectorService {
3882
3887
  const note = await ordersService.addNote(orderId, { body: `Channel ${event.type}: ${String(data.id ?? data.order_id ?? "remote order")}.` }, actor);
3883
3888
  if (!note.ok)
3884
3889
  return PluginErr(note.error?.message ?? "Could not add channel order note.");
3890
+ if (event.type === "orders/cancelled") {
3891
+ // The store cancelled: the platform follows, under a reason the cancel hook recognises, so
3892
+ // it does not turn round and cancel at the store again. An order already closed, or one the
3893
+ // machine cannot cancel (shipped), keeps its status; the note above records the delivery.
3894
+ const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
3895
+ if (order && !["cancelled", "refunded"].includes(order.status)) {
3896
+ await ordersService.changeStatus({ orderId, newStatus: "cancelled", reason: CHANNEL_ORDER_CANCELLED_REASON }, actor);
3897
+ }
3898
+ }
3885
3899
  if (event.type === "orders/fulfilled") {
3886
3900
  const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
3887
3901
  if (order?.status === "confirmed")
@@ -4283,6 +4297,39 @@ export class ChannelConnectorService {
4283
4297
  return Ok(updated);
4284
4298
  });
4285
4299
  }
4300
+ /**
4301
+ * Cancels this order at every store it was pushed to. Any refusal is returned as the error, so the
4302
+ * caller can refuse its own cancel: a store that has shipped must not see the marketplace refund
4303
+ * goods already on their way. A store with no connector able to cancel is left to its merchant.
4304
+ */
4305
+ async cancelRemoteOrders(orgId, orderId, input) {
4306
+ const exports = await this.db
4307
+ .select({ storeId: channelOrderExports.storeId, remoteOrderId: channelOrderExports.remoteOrderId })
4308
+ .from(channelOrderExports)
4309
+ .where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.orderId, orderId)));
4310
+ let cancelled = 0;
4311
+ for (const exported of exports) {
4312
+ if (exported.remoteOrderId === null)
4313
+ continue;
4314
+ const store = await this.getStoreRecord(orgId, exported.storeId);
4315
+ if (!store || store.status !== "connected")
4316
+ continue;
4317
+ // ponytail: a provider without cancelOrder is skipped silently; refuse instead if one ever ships without it.
4318
+ const connector = this.connectors.get(store.provider);
4319
+ if (!connector?.cancelOrder)
4320
+ continue;
4321
+ const result = await connector.cancelOrder(store, exported.remoteOrderId, input);
4322
+ if (!result.ok)
4323
+ return PluginErr(result.error.message, result.error.code);
4324
+ cancelled += 1;
4325
+ }
4326
+ return Ok(cancelled);
4327
+ }
4328
+ /** A cancelled or refunded order: nothing to push to a store, ever again. */
4329
+ async isOrderClosed(orgId, orderId) {
4330
+ const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
4331
+ return order?.status === "cancelled" || order?.status === "refunded";
4332
+ }
4286
4333
  async exportOrder(orgId, storeId, slice, actor) {
4287
4334
  const store = await this.getStoreRecord(orgId, storeId);
4288
4335
  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.69.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.69.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,
@@ -123,6 +124,12 @@ export const CATALOG_PUSH_MAX_ATTEMPTS = 8;
123
124
  */
124
125
  export const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
125
126
 
127
+ /**
128
+ * The reason a platform order is cancelled under when its STORE cancelled it. The cancel hook skips
129
+ * cancelling at the store for exactly this reason, so the two directions cannot loop.
130
+ */
131
+ export const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
132
+
126
133
  const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
127
134
  const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
128
135
 
@@ -4981,9 +4988,18 @@ export class ChannelConnectorService {
4981
4988
  } else if (event.type === "orders/fulfilled" || event.type === "orders/cancelled") {
4982
4989
  const orderId = await this.resolveOrderId(orgId, storeId, data);
4983
4990
  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 }> };
4991
+ 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" | "cancelled"; reason: string }, actor: Actor): Promise<{ ok: boolean }> };
4985
4992
  const note = await ordersService.addNote(orderId, { body: `Channel ${event.type}: ${String(data.id ?? data.order_id ?? "remote order")}.` }, actor);
4986
4993
  if (!note.ok) return PluginErr(note.error?.message ?? "Could not add channel order note.");
4994
+ if (event.type === "orders/cancelled") {
4995
+ // The store cancelled: the platform follows, under a reason the cancel hook recognises, so
4996
+ // it does not turn round and cancel at the store again. An order already closed, or one the
4997
+ // machine cannot cancel (shipped), keeps its status; the note above records the delivery.
4998
+ const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
4999
+ if (order && !["cancelled", "refunded"].includes(order.status)) {
5000
+ await ordersService.changeStatus({ orderId, newStatus: "cancelled", reason: CHANNEL_ORDER_CANCELLED_REASON }, actor);
5001
+ }
5002
+ }
4987
5003
  if (event.type === "orders/fulfilled") {
4988
5004
  const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
4989
5005
  if (order?.status === "confirmed") await ordersService.changeStatus({ orderId, newStatus: "processing", reason: "channel_order_fulfilled" }, actor);
@@ -5429,6 +5445,37 @@ export class ChannelConnectorService {
5429
5445
  });
5430
5446
  }
5431
5447
 
5448
+ /**
5449
+ * Cancels this order at every store it was pushed to. Any refusal is returned as the error, so the
5450
+ * caller can refuse its own cancel: a store that has shipped must not see the marketplace refund
5451
+ * goods already on their way. A store with no connector able to cancel is left to its merchant.
5452
+ */
5453
+ async cancelRemoteOrders(orgId: string, orderId: string, input: ChannelCancelOrderInput): Promise<PluginResult<number>> {
5454
+ const exports = await this.db
5455
+ .select({ storeId: channelOrderExports.storeId, remoteOrderId: channelOrderExports.remoteOrderId })
5456
+ .from(channelOrderExports)
5457
+ .where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.orderId, orderId)));
5458
+ let cancelled = 0;
5459
+ for (const exported of exports) {
5460
+ if (exported.remoteOrderId === null) continue;
5461
+ const store = await this.getStoreRecord(orgId, exported.storeId);
5462
+ if (!store || store.status !== "connected") continue;
5463
+ // ponytail: a provider without cancelOrder is skipped silently; refuse instead if one ever ships without it.
5464
+ const connector = this.connectors.get(store.provider);
5465
+ if (!connector?.cancelOrder) continue;
5466
+ const result = await connector.cancelOrder(store as ChannelStore, exported.remoteOrderId, input);
5467
+ if (!result.ok) return PluginErr(result.error.message, result.error.code);
5468
+ cancelled += 1;
5469
+ }
5470
+ return Ok(cancelled);
5471
+ }
5472
+
5473
+ /** A cancelled or refunded order: nothing to push to a store, ever again. */
5474
+ async isOrderClosed(orgId: string, orderId: string): Promise<boolean> {
5475
+ const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
5476
+ return order?.status === "cancelled" || order?.status === "refunded";
5477
+ }
5478
+
5432
5479
  async exportOrder(
5433
5480
  orgId: string,
5434
5481
  storeId: string,