@porulle/plugin-channel-connector 0.74.5 → 0.76.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/index.d.ts CHANGED
@@ -27,5 +27,5 @@ export declare const CHANNEL_MAX_BATCHES_PER_SWEEP = 5000;
27
27
  export { signState, verifyState } from "./oauth-state.js";
28
28
  export type { BackfillCatalogOptions, BackfillCatalogReport, BuildCatalogPushItemsOptions, BuildCatalogPushItemsResult, CatalogPushAssemblyField, CatalogPushAssemblyImage, CatalogPushAssemblyItem, CatalogPushPreviewBefore, CatalogPushPreviewBeforeStatus, CatalogPushPreviewDiff, CatalogPushPreviewItem, CatalogPushPreviewResult, CatalogPushPreviewUnavailable, PushCatalogToStoreResult, CatalogPushJobResult, CatalogConvergenceFailure, CatalogFieldConflict, CatalogFieldSkip, CatalogPushFieldSkip, CatalogPushSkipReason, CatalogConflictState, CatalogWriteSettings, AfterStoreConnected, BindConnectedStore, ChannelComplianceData, ChannelConnectorPluginOptions, ConfineStores, ConnectClaims, OnStoreCatalogChanged, StoreConnectActor, StoreReadContext, ChannelStockLine, ExportState, PublicConnectedStore, ReconcileReport, } from "./service.js";
29
29
  export type { OAuthStatePayload, OAuthStateResult } from "./oauth-state.js";
30
- export type { ChannelCatalogPush, ChannelCatalogPushEvent, ChannelCatalogConflict, ChannelCatalogConflictEvent, ChannelEntityMapEntry, ChannelExportEvent, ChannelOrderExport, ChannelRefundEvent, ChannelRefundRequest, ConnectedStore, StoreHealth, } from "./schema.js";
30
+ export type { ChannelCatalogPush, ChannelCatalogPushEvent, ChannelCatalogConflict, ChannelCatalogConflictEvent, ChannelEntityMapEntry, ChannelExportEvent, ChannelOrderExport, ChannelRefundEvent, ChannelRefundRequest, ChannelReturn, ConnectedStore, StoreHealth, } from "./schema.js";
31
31
  export declare function channelConnectorPlugin(options?: ChannelConnectorPluginOptions): import("@porulle/core").CommercePlugin;
package/dist/index.js CHANGED
@@ -732,6 +732,18 @@ export function channelConnectorPlugin(options = {}) {
732
732
  .summary("Reject a channel refund request")
733
733
  .permission("channels:manage")
734
734
  .handler(async ({ params, orgId, actor }) => unwrap(await service.rejectRefund(orgId, params.id, { userId: requireUserId(actor) })));
735
+ channels.get("/returns")
736
+ .summary("List the returns held on the platform that wait for their merchant")
737
+ .permission("channels:connect")
738
+ .handler(async ({ orgId, actor, raw }) => unwrap(await service.listReturns(orgId, { orgId, actor, raw })));
739
+ channels.post("/returns/{id}/approve")
740
+ .summary("Approve a held return: pay the shopper back and book the refund at the store")
741
+ .permission("channels:connect")
742
+ .handler(async ({ params, orgId, actor, raw }) => unwrap(await service.approveReturn(orgId, params.id, { orgId, actor, raw })));
743
+ channels.post("/returns/{id}/decline")
744
+ .summary("Decline a held return")
745
+ .permission("channels:connect")
746
+ .handler(async ({ params, orgId, actor, raw }) => unwrap(await service.declineReturn(orgId, params.id, { orgId, actor, raw })));
735
747
  channels.post("/exports/{id}/retry")
736
748
  .summary("Retry a failed channel order export")
737
749
  .permission("channels:manage")
@@ -73,6 +73,7 @@ export declare function mockChannelConnector(options?: MockChannelConnectorOptio
73
73
  externalVariantId: string;
74
74
  quantity: number;
75
75
  }[];
76
+ amount?: number;
76
77
  } | {
77
78
  kind: "return.updated";
78
79
  remoteReturnId: string;
@@ -65,7 +65,7 @@ const channelEventSchema = z.discriminatedUnion("kind", [
65
65
  z.object({ kind: z.literal("inventory.changed"), levels: z.array(level) }),
66
66
  z.object({ kind: z.literal("order.cancelled"), remoteOrderId: z.string() }),
67
67
  z.object({ kind: z.literal("order.fulfilled"), remoteOrderId: z.string(), partial: z.boolean(), shipments: z.array(shipment) }),
68
- z.object({ kind: z.literal("refund.created"), remoteOrderId: z.string(), remoteRefundId: z.string(), lines: z.array(z.object({ externalVariantId: z.string(), quantity: z.number().int() })) }),
68
+ z.object({ kind: z.literal("refund.created"), remoteOrderId: z.string(), remoteRefundId: z.string(), lines: z.array(z.object({ externalVariantId: z.string(), quantity: z.number().int() })), amount: z.number().int().exactOptional() }),
69
69
  z.object({ kind: z.literal("return.updated"), remoteReturnId: z.string(), status: z.enum(["approved", "declined", "closed", "cancelled"]) }),
70
70
  z.object({ kind: z.literal("connection.revoked") }),
71
71
  z.object({ kind: z.literal("compliance.request"), request: z.enum(["customer_data", "customer_redact", "shop_redact"]), data: z.record(z.string(), z.unknown()) }),
package/dist/schema.d.ts CHANGED
@@ -2106,6 +2106,31 @@ export declare const channelRefundRequests: import("drizzle-orm/pg-core/table").
2106
2106
  identity: undefined;
2107
2107
  generated: undefined;
2108
2108
  }, {}, {}>;
2109
+ lines: import("@porulle/core/drizzle").PgColumn<{
2110
+ name: "lines";
2111
+ tableName: "channel_refund_requests";
2112
+ dataType: "json";
2113
+ columnType: "PgJsonb";
2114
+ data: {
2115
+ lineItemId: string;
2116
+ quantity: number;
2117
+ }[];
2118
+ driverParam: unknown;
2119
+ notNull: false;
2120
+ hasDefault: false;
2121
+ isPrimaryKey: false;
2122
+ isAutoincrement: false;
2123
+ hasRuntimeDefault: false;
2124
+ enumValues: undefined;
2125
+ baseColumn: never;
2126
+ identity: undefined;
2127
+ generated: undefined;
2128
+ }, {}, {
2129
+ $type: {
2130
+ lineItemId: string;
2131
+ quantity: number;
2132
+ }[];
2133
+ }>;
2109
2134
  state: import("@porulle/core/drizzle").PgColumn<{
2110
2135
  name: "state";
2111
2136
  tableName: "channel_refund_requests";
@@ -2329,4 +2354,5 @@ export type ChannelCatalogPushEvent = typeof channelCatalogPushEvents.$inferSele
2329
2354
  export type ChannelOrderExport = typeof channelOrderExports.$inferSelect;
2330
2355
  export type ChannelExportEvent = typeof channelExportEvents.$inferSelect;
2331
2356
  export type ChannelRefundRequest = typeof channelRefundRequests.$inferSelect;
2357
+ export type ChannelReturn = typeof channelReturns.$inferSelect;
2332
2358
  export type ChannelRefundEvent = typeof channelRefundEvents.$inferSelect;
package/dist/schema.js CHANGED
@@ -199,6 +199,8 @@ export const channelRefundRequests = pgTable("channel_refund_requests", {
199
199
  orderId: uuid("order_id").notNull(),
200
200
  remoteRefundId: text("remote_refund_id").notNull(),
201
201
  amount: integer("amount").notNull(),
202
+ /** The order lines the store refunded. Null on requests made before it was kept. */
203
+ lines: jsonb("lines").$type(),
202
204
  state: text("state", { enum: ["requested", "approved", "rejected", "executed"] }).notNull().default("requested"),
203
205
  approvedBy: text("approved_by"),
204
206
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
package/dist/service.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { Actor, ChannelCancelOrderInput, ChannelCatalogItem, ChannelConnect
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";
6
- import { type ChannelCatalogPush, type ChannelCatalogConflict, type ChannelOrderExport, type ChannelRefundRequest, type ConnectedStore } from "./schema.js";
6
+ import { type ChannelCatalogPush, type ChannelCatalogConflict, type ChannelOrderExport, type ChannelRefundRequest, type ChannelReturn, type ConnectedStore } from "./schema.js";
7
7
  import type { StoreHealth } from "./schema.js";
8
8
  import { type CatalogFieldMapping, type CatalogFieldTarget } from "./catalog-field-mapping.js";
9
9
  export type ExportState = ChannelOrderExport["state"];
@@ -31,6 +31,8 @@ export declare const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
31
31
  export declare const REMOTE_ORDER_REFRESH_MS: number;
32
32
  /** How often a merchant's visit may make the plugin call a store to check its health. */
33
33
  export declare const STORE_HEALTH_INTERVAL_MS: number;
34
+ /** The `remote_return_id` of a return held on the platform, for a store with no returns of its own. */
35
+ export declare const PLATFORM_RETURN_PREFIX = "platform:";
34
36
  export declare const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
35
37
  export declare function catalogPushRetryDelayMs(attempts: number): number;
36
38
  export interface CatalogPushJobResult extends Record<string, unknown> {
@@ -752,6 +754,17 @@ export declare class ChannelConnectorService {
752
754
  remoteReturnId: string;
753
755
  status: string;
754
756
  }>>;
757
+ /** Returns held on the platform (stores with none of their own) that wait for their merchant. */
758
+ listReturns(orgId: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturn[]>>;
759
+ /**
760
+ * The merchant takes a held return back: the shopper is paid back those lines, then the refund is
761
+ * booked at the store with the stock put back, and kept as an executed refund request under the
762
+ * store's own refund id so the store's webhook for it pays nobody twice. If the store will not book
763
+ * it, the shopper has still been paid and the return stays `approved`; approving again only books it.
764
+ */
765
+ approveReturn(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturn>>;
766
+ /** The merchant refuses a held return. Nothing moves. */
767
+ declineReturn(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturn>>;
755
768
  /** A cancelled or refunded order: nothing to push to a store, ever again. */
756
769
  isOrderClosed(orgId: string, orderId: string): Promise<boolean>;
757
770
  exportOrder(orgId: string, storeId: string, slice: ChannelOrderSlice, actor: Actor): Promise<PluginResult<ChannelOrderExport>>;
package/dist/service.js CHANGED
@@ -37,6 +37,8 @@ export const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
37
37
  export const REMOTE_ORDER_REFRESH_MS = 5 * 60 * 1000;
38
38
  /** How often a merchant's visit may make the plugin call a store to check its health. */
39
39
  export const STORE_HEALTH_INTERVAL_MS = 10 * 60 * 1000;
40
+ /** The `remote_return_id` of a return held on the platform, for a store with no returns of its own. */
41
+ export const PLATFORM_RETURN_PREFIX = "platform:";
40
42
  export const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
41
43
  const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
42
44
  const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
@@ -4185,17 +4187,21 @@ export class ChannelConnectorService {
4185
4187
  else
4186
4188
  refundLines.push({ lineItemId: orderLine.id, quantity });
4187
4189
  }
4188
- const amount = refundLines.reduce((sum, line) => {
4190
+ const priced = refundLines.reduce((sum, line) => {
4189
4191
  const item = orderLines.find((candidate) => candidate.id === line.lineItemId);
4190
4192
  return sum + Math.round((item.totalPrice + item.taxAmount - item.discountAmount) * line.quantity / item.quantity);
4191
4193
  }, 0);
4194
+ // What the store refunded, when it says, and never more than the platform's own price for the lines:
4195
+ // a store can refund part of a line, or a line it discounted, but cannot claim more than it sold.
4196
+ const amount = event.amount === undefined ? priced : Math.min(Math.max(0, event.amount), priced);
4192
4197
  const [order] = await this.db.select().from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
4193
4198
  if (!order)
4194
4199
  return PluginErr("Order not found.", "NOT_FOUND");
4195
4200
  const max = this.options.refundAutoMax ?? order.amountCaptured ?? order.grandTotal;
4196
4201
  const ageOk = Date.now() - store.createdAt.getTime() >= (this.options.newStoreDays ?? 7) * 86_400_000;
4197
- const auto = clean && amount > 0 && ageOk && amount <= max;
4198
- const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, state: auto ? "approved" : "requested", approvedBy: auto ? requireUserId(actor) : null }).returning();
4202
+ // Only a whole-line refund is automatic; any other amount is a person's call.
4203
+ const auto = clean && amount > 0 && amount === priced && ageOk && amount <= max;
4204
+ const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, lines: clean ? refundLines : null, state: auto ? "approved" : "requested", approvedBy: auto ? requireUserId(actor) : null }).returning();
4199
4205
  const request = rows[0];
4200
4206
  await this.db.insert(channelRefundEvents).values({ organizationId: orgId, requestId: request.id, fromState: null, toState: request.state, reason: auto ? "Automatic guarded refund" : "Operator approval required", changedBy: requireUserId(actor) });
4201
4207
  if (auto) {
@@ -4211,7 +4217,8 @@ export class ChannelConnectorService {
4211
4217
  }
4212
4218
  async executeRefund(request, lines, actor) {
4213
4219
  const ordersService = this.services.orders;
4214
- const result = await ordersService.refundLines(request.orderId, { lines, reason: `Channel refund ${request.remoteRefundId}` }, actor);
4220
+ // A request that kept its lines pays back its own amount; an older one is priced from the lines rebuilt for it.
4221
+ const result = await ordersService.refundLines(request.orderId, { lines, reason: `Channel refund ${request.remoteRefundId}`, ...(request.lines ? { amount: request.amount } : {}) }, actor);
4215
4222
  if (!result.ok)
4216
4223
  return PluginErr(result.error?.message ?? "Refund execution failed.");
4217
4224
  const [updated] = await this.db.update(channelRefundRequests).set({ state: "executed", updatedAt: new Date() }).where(and(eq(channelRefundRequests.organizationId, request.organizationId), eq(channelRefundRequests.id, request.id), eq(channelRefundRequests.state, "approved"))).returning();
@@ -4225,7 +4232,7 @@ export class ChannelConnectorService {
4225
4232
  const [request] = await this.db.update(channelRefundRequests).set({ state: "approved", approvedBy: actor.userId, updatedAt: new Date() }).where(and(eq(channelRefundRequests.organizationId, orgId), eq(channelRefundRequests.id, id), eq(channelRefundRequests.state, "requested"))).returning();
4226
4233
  if (!request)
4227
4234
  return PluginErr("Refund request not found or already handled.", "NOT_FOUND");
4228
- const lines = await this.refundLinesForRequest(request);
4235
+ const lines = request.lines ?? await this.refundLinesForRequest(request);
4229
4236
  const executed = await this.executeRefund(request, lines, createSystemActor(orgId));
4230
4237
  if (!executed.ok) {
4231
4238
  // Nothing moved: back to `requested`, so the operator can approve again once the cause is fixed.
@@ -4411,7 +4418,9 @@ export class ChannelConnectorService {
4411
4418
  if (!store || store.status !== "connected")
4412
4419
  return PluginErr("The store this order went to is not connected.", "NOT_FOUND");
4413
4420
  const connector = this.connectors.get(store.provider);
4414
- if (!connector?.requestReturn)
4421
+ // A store with no returns of its own (WooCommerce) has them held here, for its merchant to approve.
4422
+ const hosted = connector?.requestReturn === undefined && connector?.recordRefund !== undefined;
4423
+ if (!connector || (!connector.requestReturn && !hosted))
4415
4424
  return PluginErr(`Returns are not available for ${store.provider} stores.`, "NOT_IMPLEMENTED");
4416
4425
  const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
4417
4426
  const variantIds = lines.flatMap((line) => (line.variantId === null ? [] : [line.variantId]));
@@ -4431,14 +4440,18 @@ export class ChannelConnectorService {
4431
4440
  return PluginErr(`The store has no record of line ${wanted.orderLineItemId}, so it cannot take it back.`, "CHANNEL_MAPPING_MISSING");
4432
4441
  remote.push({ externalVariantId: externalId, quantity: wanted.quantity });
4433
4442
  }
4434
- const asked = await connector.requestReturn(store, exported.remoteOrderId, { lines: remote, reason: input.reason, ...(input.note ? { note: input.note } : {}) });
4435
- if (!asked.ok)
4436
- return PluginErr(asked.error.message, asked.error.code);
4443
+ let remoteReturnId = `${PLATFORM_RETURN_PREFIX}${crypto.randomUUID()}`;
4444
+ if (connector.requestReturn) {
4445
+ const asked = await connector.requestReturn(store, exported.remoteOrderId, { lines: remote, reason: input.reason, ...(input.note ? { note: input.note } : {}) });
4446
+ if (!asked.ok)
4447
+ return PluginErr(asked.error.message, asked.error.code);
4448
+ remoteReturnId = asked.value.remoteReturnId;
4449
+ }
4437
4450
  const [row] = await this.db.insert(channelReturns).values({
4438
4451
  organizationId: orgId,
4439
4452
  storeId: store.id,
4440
4453
  orderId,
4441
- remoteReturnId: asked.value.remoteReturnId,
4454
+ remoteReturnId,
4442
4455
  status: "requested",
4443
4456
  lines: input.lines,
4444
4457
  reason: input.reason,
@@ -4448,6 +4461,86 @@ export class ChannelConnectorService {
4448
4461
  return PluginErr("The return could not be recorded.");
4449
4462
  return Ok(row);
4450
4463
  }
4464
+ /** Returns held on the platform (stores with none of their own) that wait for their merchant. */
4465
+ async listReturns(orgId, context) {
4466
+ const allowed = await this.allowedStores(orgId, context);
4467
+ if (allowed !== null && allowed.length === 0)
4468
+ return Ok([]);
4469
+ const rows = await this.db.select().from(channelReturns).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.status, "requested"), sql `${channelReturns.remoteReturnId} like ${`${PLATFORM_RETURN_PREFIX}%`}`, ...(allowed === null ? [] : [inArray(channelReturns.storeId, [...allowed])]))).orderBy(desc(channelReturns.createdAt));
4470
+ return Ok(rows);
4471
+ }
4472
+ /**
4473
+ * The merchant takes a held return back: the shopper is paid back those lines, then the refund is
4474
+ * booked at the store with the stock put back, and kept as an executed refund request under the
4475
+ * store's own refund id so the store's webhook for it pays nobody twice. If the store will not book
4476
+ * it, the shopper has still been paid and the return stays `approved`; approving again only books it.
4477
+ */
4478
+ async approveReturn(orgId, id, context) {
4479
+ const [held] = await this.db.select().from(channelReturns).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, id)));
4480
+ if (!held || !held.remoteReturnId.startsWith(PLATFORM_RETURN_PREFIX))
4481
+ return PluginErr("Return not found.", "NOT_FOUND");
4482
+ const reached = await this.reachableStore(orgId, held.storeId, context);
4483
+ if (!reached.ok)
4484
+ return PluginErr("Return not found.", "NOT_FOUND");
4485
+ if (held.status !== "requested" && held.status !== "approved")
4486
+ return PluginErr(`This return is already ${held.status}.`, "CONFLICT");
4487
+ const store = reached.value;
4488
+ const connector = this.connectors.get(store.provider);
4489
+ if (!connector?.recordRefund)
4490
+ return PluginErr(`Returns are not available for ${store.provider} stores.`, "NOT_IMPLEMENTED");
4491
+ const [exported] = await this.db.select({ remoteOrderId: channelOrderExports.remoteOrderId }).from(channelOrderExports)
4492
+ .where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.orderId, held.orderId), eq(channelOrderExports.storeId, store.id)));
4493
+ if (!exported?.remoteOrderId)
4494
+ return PluginErr("This order never reached the store.", "NOT_FOUND");
4495
+ const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, held.orderId));
4496
+ const priced = [];
4497
+ for (const line of held.lines) {
4498
+ const item = orderLines.find((candidate) => candidate.id === line.orderLineItemId);
4499
+ if (!item)
4500
+ return PluginErr(`Line ${line.orderLineItemId} is no longer on this order.`, "VALIDATION_FAILED");
4501
+ priced.push({ lineItemId: item.id, quantity: line.quantity, variantId: item.variantId, amount: Math.round((item.totalPrice + item.taxAmount - item.discountAmount) * line.quantity / item.quantity) });
4502
+ }
4503
+ const actor = createSystemActor(orgId);
4504
+ if (held.status === "requested") {
4505
+ const ordersService = this.services.orders;
4506
+ const refunded = await ordersService.refundLines(held.orderId, { lines: priced.map(({ lineItemId, quantity }) => ({ lineItemId, quantity })), reason: `Return ${held.id}` }, actor);
4507
+ if (!refunded.ok)
4508
+ return PluginErr(refunded.error?.message ?? "The shopper could not be paid back.", "REFUND_FAILED");
4509
+ await this.db.update(channelReturns).set({ status: "approved", updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id)));
4510
+ }
4511
+ const variantIds = priced.flatMap((line) => (line.variantId === null ? [] : [line.variantId]));
4512
+ const mapped = variantIds.length === 0 ? [] : await this.db.select({ variantId: channelEntityMap.variantId, externalId: channelEntityMap.externalId }).from(channelEntityMap)
4513
+ .where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.variantId, variantIds)));
4514
+ const storeLines = [];
4515
+ for (const line of priced) {
4516
+ const externalId = mapped.find((entry) => entry.variantId === line.variantId)?.externalId;
4517
+ if (!externalId)
4518
+ return PluginErr("The shopper was paid back, but the store has no record of a returned line, so it was not booked there.", "CHANNEL_MAPPING_MISSING");
4519
+ storeLines.push({ externalVariantId: externalId, quantity: line.quantity, amount: line.amount });
4520
+ }
4521
+ const amount = storeLines.reduce((sum, line) => sum + line.amount, 0);
4522
+ const booked = await connector.recordRefund(store, exported.remoteOrderId, { lines: storeLines, amount, reason: `Return: ${held.reason}`, restock: true });
4523
+ if (!booked.ok)
4524
+ return PluginErr(`The shopper was paid back, but the store did not record the refund (${booked.error.message}). Approve again to retry.`, "CHANNEL_REFUND_NOT_RECORDED");
4525
+ const lines = priced.map(({ lineItemId, quantity }) => ({ lineItemId, quantity }));
4526
+ await this.db.insert(channelRefundRequests)
4527
+ .values({ organizationId: orgId, storeId: store.id, orderId: held.orderId, remoteRefundId: booked.value.remoteRefundId, amount, lines, state: "executed", approvedBy: requireUserId(actor) })
4528
+ .onConflictDoUpdate({ target: [channelRefundRequests.storeId, channelRefundRequests.remoteRefundId], set: { amount, lines, state: "executed", updatedAt: new Date() } });
4529
+ const [closed] = await this.db.update(channelReturns).set({ status: "closed", updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id))).returning();
4530
+ return closed ? Ok(closed) : PluginErr("Return not found.", "NOT_FOUND");
4531
+ }
4532
+ /** The merchant refuses a held return. Nothing moves. */
4533
+ async declineReturn(orgId, id, context) {
4534
+ const [held] = await this.db.select().from(channelReturns).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, id)));
4535
+ if (!held || !held.remoteReturnId.startsWith(PLATFORM_RETURN_PREFIX))
4536
+ return PluginErr("Return not found.", "NOT_FOUND");
4537
+ const reached = await this.reachableStore(orgId, held.storeId, context);
4538
+ if (!reached.ok)
4539
+ return PluginErr("Return not found.", "NOT_FOUND");
4540
+ const [declined] = await this.db.update(channelReturns).set({ status: "declined", updatedAt: new Date() })
4541
+ .where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id), eq(channelReturns.status, "requested"))).returning();
4542
+ return declined ? Ok(declined) : PluginErr(`This return is already ${held.status}.`, "CONFLICT");
4543
+ }
4451
4544
  /** A cancelled or refunded order: nothing to push to a store, ever again. */
4452
4545
  async isOrderClosed(orgId, orderId) {
4453
4546
  const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.74.5",
3
+ "version": "0.76.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -22,7 +22,7 @@
22
22
  "dependencies": {
23
23
  "@hono/zod-openapi": "^1.2.2",
24
24
  "hono": "^4.12.5",
25
- "@porulle/core": "0.74.5"
25
+ "@porulle/core": "0.76.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^24.5.2",
package/src/index.ts CHANGED
@@ -259,6 +259,7 @@ export type {
259
259
  ChannelOrderExport,
260
260
  ChannelRefundEvent,
261
261
  ChannelRefundRequest,
262
+ ChannelReturn,
262
263
  ConnectedStore,
263
264
  StoreHealth,
264
265
  } from "./schema.js";
@@ -909,6 +910,21 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
909
910
  .permission("channels:manage")
910
911
  .handler(async ({ params, orgId, actor }: ChannelRouteContext) => unwrap(await service.rejectRefund(orgId, params.id!, { userId: requireUserId(actor) })));
911
912
 
913
+ channels.get("/returns")
914
+ .summary("List the returns held on the platform that wait for their merchant")
915
+ .permission("channels:connect")
916
+ .handler(async ({ orgId, actor, raw }: ChannelRouteContext) => unwrap(await service.listReturns(orgId, { orgId, actor, raw })));
917
+
918
+ channels.post("/returns/{id}/approve")
919
+ .summary("Approve a held return: pay the shopper back and book the refund at the store")
920
+ .permission("channels:connect")
921
+ .handler(async ({ params, orgId, actor, raw }: ChannelRouteContext) => unwrap(await service.approveReturn(orgId, params.id!, { orgId, actor, raw })));
922
+
923
+ channels.post("/returns/{id}/decline")
924
+ .summary("Decline a held return")
925
+ .permission("channels:connect")
926
+ .handler(async ({ params, orgId, actor, raw }: ChannelRouteContext) => unwrap(await service.declineReturn(orgId, params.id!, { orgId, actor, raw })));
927
+
912
928
  channels.post("/exports/{id}/retry")
913
929
  .summary("Retry a failed channel order export")
914
930
  .permission("channels:manage")
@@ -94,7 +94,7 @@ const channelEventSchema = z.discriminatedUnion("kind", [
94
94
  z.object({ kind: z.literal("inventory.changed"), levels: z.array(level) }),
95
95
  z.object({ kind: z.literal("order.cancelled"), remoteOrderId: z.string() }),
96
96
  z.object({ kind: z.literal("order.fulfilled"), remoteOrderId: z.string(), partial: z.boolean(), shipments: z.array(shipment) }),
97
- z.object({ kind: z.literal("refund.created"), remoteOrderId: z.string(), remoteRefundId: z.string(), lines: z.array(z.object({ externalVariantId: z.string(), quantity: z.number().int() })) }),
97
+ z.object({ kind: z.literal("refund.created"), remoteOrderId: z.string(), remoteRefundId: z.string(), lines: z.array(z.object({ externalVariantId: z.string(), quantity: z.number().int() })), amount: z.number().int().exactOptional() }),
98
98
  z.object({ kind: z.literal("return.updated"), remoteReturnId: z.string(), status: z.enum(["approved", "declined", "closed", "cancelled"]) }),
99
99
  z.object({ kind: z.literal("connection.revoked") }),
100
100
  z.object({ kind: z.literal("compliance.request"), request: z.enum(["customer_data", "customer_redact", "shop_redact"]), data: z.record(z.string(), z.unknown()) }),
package/src/schema.ts CHANGED
@@ -287,6 +287,8 @@ export const channelRefundRequests = pgTable(
287
287
  orderId: uuid("order_id").notNull(),
288
288
  remoteRefundId: text("remote_refund_id").notNull(),
289
289
  amount: integer("amount").notNull(),
290
+ /** The order lines the store refunded. Null on requests made before it was kept. */
291
+ lines: jsonb("lines").$type<Array<{ lineItemId: string; quantity: number }>>(),
290
292
  state: text("state", { enum: ["requested", "approved", "rejected", "executed"] }).notNull().default("requested"),
291
293
  approvedBy: text("approved_by"),
292
294
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
@@ -326,4 +328,5 @@ export type ChannelCatalogPushEvent = typeof channelCatalogPushEvents.$inferSele
326
328
  export type ChannelOrderExport = typeof channelOrderExports.$inferSelect;
327
329
  export type ChannelExportEvent = typeof channelExportEvents.$inferSelect;
328
330
  export type ChannelRefundRequest = typeof channelRefundRequests.$inferSelect;
331
+ export type ChannelReturn = typeof channelReturns.$inferSelect;
329
332
  export type ChannelRefundEvent = typeof channelRefundEvents.$inferSelect;
package/src/service.ts CHANGED
@@ -94,6 +94,7 @@ import {
94
94
  type ChannelCatalogConflict,
95
95
  type ChannelOrderExport,
96
96
  type ChannelRefundRequest,
97
+ type ChannelReturn,
97
98
  type ConnectedStore,
98
99
  } from "./schema.js";
99
100
  import type { StoreHealth } from "./schema.js";
@@ -142,6 +143,8 @@ export const REMOTE_ORDER_REFRESH_MS = 5 * 60 * 1000;
142
143
 
143
144
  /** How often a merchant's visit may make the plugin call a store to check its health. */
144
145
  export const STORE_HEALTH_INTERVAL_MS = 10 * 60 * 1000;
146
+ /** The `remote_return_id` of a return held on the platform, for a store with no returns of its own. */
147
+ export const PLATFORM_RETURN_PREFIX = "platform:";
145
148
 
146
149
  export const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
147
150
 
@@ -5327,16 +5330,20 @@ export class ChannelConnectorService {
5327
5330
  if (!orderLine || !Number.isInteger(quantity) || quantity < 1 || quantity > orderLine.quantity - orderLine.refundedQuantity) clean = false;
5328
5331
  else refundLines.push({ lineItemId: orderLine.id, quantity });
5329
5332
  }
5330
- const amount = refundLines.reduce((sum, line) => {
5333
+ const priced = refundLines.reduce((sum, line) => {
5331
5334
  const item = orderLines.find((candidate) => candidate.id === line.lineItemId)!;
5332
5335
  return sum + Math.round((item.totalPrice + item.taxAmount - item.discountAmount) * line.quantity / item.quantity);
5333
5336
  }, 0);
5337
+ // What the store refunded, when it says, and never more than the platform's own price for the lines:
5338
+ // a store can refund part of a line, or a line it discounted, but cannot claim more than it sold.
5339
+ const amount = event.amount === undefined ? priced : Math.min(Math.max(0, event.amount), priced);
5334
5340
  const [order] = await this.db.select().from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
5335
5341
  if (!order) return PluginErr("Order not found.", "NOT_FOUND");
5336
5342
  const max = this.options.refundAutoMax ?? order.amountCaptured ?? order.grandTotal;
5337
5343
  const ageOk = Date.now() - store.createdAt.getTime() >= (this.options.newStoreDays ?? 7) * 86_400_000;
5338
- const auto = clean && amount > 0 && ageOk && amount <= max;
5339
- const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, state: auto ? "approved" : "requested", approvedBy: auto ? requireUserId(actor) : null }).returning();
5344
+ // Only a whole-line refund is automatic; any other amount is a person's call.
5345
+ const auto = clean && amount > 0 && amount === priced && ageOk && amount <= max;
5346
+ const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, lines: clean ? refundLines : null, state: auto ? "approved" : "requested", approvedBy: auto ? requireUserId(actor) : null }).returning();
5340
5347
  const request = rows[0] as ChannelRefundRequest;
5341
5348
  await this.db.insert(channelRefundEvents).values({ organizationId: orgId, requestId: request.id, fromState: null, toState: request.state, reason: auto ? "Automatic guarded refund" : "Operator approval required", changedBy: requireUserId(actor) });
5342
5349
  if (auto) {
@@ -5352,8 +5359,9 @@ export class ChannelConnectorService {
5352
5359
  }
5353
5360
 
5354
5361
  private async executeRefund(request: ChannelRefundRequest, lines: Array<{ lineItemId: string; quantity: number }>, actor: Actor): Promise<PluginResult<ChannelRefundRequest>> {
5355
- const ordersService = this.services.orders as { refundLines(orderId: string, input: { lines: Array<{ lineItemId: string; quantity: number }>; reason?: string }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }> };
5356
- const result = await ordersService.refundLines(request.orderId, { lines, reason: `Channel refund ${request.remoteRefundId}` }, actor);
5362
+ const ordersService = this.services.orders as { refundLines(orderId: string, input: { lines: Array<{ lineItemId: string; quantity: number }>; reason?: string; amount?: number }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }> };
5363
+ // A request that kept its lines pays back its own amount; an older one is priced from the lines rebuilt for it.
5364
+ const result = await ordersService.refundLines(request.orderId, { lines, reason: `Channel refund ${request.remoteRefundId}`, ...(request.lines ? { amount: request.amount } : {}) }, actor);
5357
5365
  if (!result.ok) return PluginErr(result.error?.message ?? "Refund execution failed.");
5358
5366
  const [updated] = await this.db.update(channelRefundRequests).set({ state: "executed", updatedAt: new Date() }).where(and(eq(channelRefundRequests.organizationId, request.organizationId), eq(channelRefundRequests.id, request.id), eq(channelRefundRequests.state, "approved"))).returning();
5359
5367
  await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: requireUserId(actor) });
@@ -5367,7 +5375,7 @@ export class ChannelConnectorService {
5367
5375
  async approveRefund(orgId: string, id: string, actor: { userId: string }): Promise<PluginResult<ChannelRefundRequest>> {
5368
5376
  const [request] = await this.db.update(channelRefundRequests).set({ state: "approved", approvedBy: actor.userId, updatedAt: new Date() }).where(and(eq(channelRefundRequests.organizationId, orgId), eq(channelRefundRequests.id, id), eq(channelRefundRequests.state, "requested"))).returning();
5369
5377
  if (!request) return PluginErr("Refund request not found or already handled.", "NOT_FOUND");
5370
- const lines = await this.refundLinesForRequest(request as ChannelRefundRequest);
5378
+ const lines = request.lines ?? await this.refundLinesForRequest(request as ChannelRefundRequest);
5371
5379
  const executed = await this.executeRefund(request as ChannelRefundRequest, lines, createSystemActor(orgId));
5372
5380
  if (!executed.ok) {
5373
5381
  // Nothing moved: back to `requested`, so the operator can approve again once the cause is fixed.
@@ -5577,7 +5585,9 @@ export class ChannelConnectorService {
5577
5585
  const store = await this.getStoreRecord(orgId, exported.storeId);
5578
5586
  if (!store || store.status !== "connected") return PluginErr("The store this order went to is not connected.", "NOT_FOUND");
5579
5587
  const connector = this.connectors.get(store.provider);
5580
- if (!connector?.requestReturn) return PluginErr(`Returns are not available for ${store.provider} stores.`, "NOT_IMPLEMENTED");
5588
+ // A store with no returns of its own (WooCommerce) has them held here, for its merchant to approve.
5589
+ const hosted = connector?.requestReturn === undefined && connector?.recordRefund !== undefined;
5590
+ if (!connector || (!connector.requestReturn && !hosted)) return PluginErr(`Returns are not available for ${store.provider} stores.`, "NOT_IMPLEMENTED");
5581
5591
 
5582
5592
  const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
5583
5593
  const variantIds = lines.flatMap((line) => (line.variantId === null ? [] : [line.variantId]));
@@ -5595,13 +5605,17 @@ export class ChannelConnectorService {
5595
5605
  remote.push({ externalVariantId: externalId, quantity: wanted.quantity });
5596
5606
  }
5597
5607
 
5598
- const asked = await connector.requestReturn(store as ChannelStore, exported.remoteOrderId, { lines: remote, reason: input.reason, ...(input.note ? { note: input.note } : {}) });
5599
- if (!asked.ok) return PluginErr(asked.error.message, asked.error.code);
5608
+ let remoteReturnId = `${PLATFORM_RETURN_PREFIX}${crypto.randomUUID()}`;
5609
+ if (connector.requestReturn) {
5610
+ const asked = await connector.requestReturn(store as ChannelStore, exported.remoteOrderId, { lines: remote, reason: input.reason, ...(input.note ? { note: input.note } : {}) });
5611
+ if (!asked.ok) return PluginErr(asked.error.message, asked.error.code);
5612
+ remoteReturnId = asked.value.remoteReturnId;
5613
+ }
5600
5614
  const [row] = await this.db.insert(channelReturns).values({
5601
5615
  organizationId: orgId,
5602
5616
  storeId: store.id,
5603
5617
  orderId,
5604
- remoteReturnId: asked.value.remoteReturnId,
5618
+ remoteReturnId,
5605
5619
  status: "requested",
5606
5620
  lines: input.lines,
5607
5621
  reason: input.reason,
@@ -5611,6 +5625,84 @@ export class ChannelConnectorService {
5611
5625
  return Ok(row);
5612
5626
  }
5613
5627
 
5628
+ /** Returns held on the platform (stores with none of their own) that wait for their merchant. */
5629
+ async listReturns(orgId: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturn[]>> {
5630
+ const allowed = await this.allowedStores(orgId, context);
5631
+ if (allowed !== null && allowed.length === 0) return Ok([]);
5632
+ const rows = await this.db.select().from(channelReturns).where(and(
5633
+ eq(channelReturns.organizationId, orgId),
5634
+ eq(channelReturns.status, "requested"),
5635
+ sql`${channelReturns.remoteReturnId} like ${`${PLATFORM_RETURN_PREFIX}%`}`,
5636
+ ...(allowed === null ? [] : [inArray(channelReturns.storeId, [...allowed])]),
5637
+ )).orderBy(desc(channelReturns.createdAt));
5638
+ return Ok(rows);
5639
+ }
5640
+
5641
+ /**
5642
+ * The merchant takes a held return back: the shopper is paid back those lines, then the refund is
5643
+ * booked at the store with the stock put back, and kept as an executed refund request under the
5644
+ * store's own refund id so the store's webhook for it pays nobody twice. If the store will not book
5645
+ * it, the shopper has still been paid and the return stays `approved`; approving again only books it.
5646
+ */
5647
+ async approveReturn(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturn>> {
5648
+ const [held] = await this.db.select().from(channelReturns).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, id)));
5649
+ if (!held || !held.remoteReturnId.startsWith(PLATFORM_RETURN_PREFIX)) return PluginErr("Return not found.", "NOT_FOUND");
5650
+ const reached = await this.reachableStore(orgId, held.storeId, context);
5651
+ if (!reached.ok) return PluginErr("Return not found.", "NOT_FOUND");
5652
+ if (held.status !== "requested" && held.status !== "approved") return PluginErr(`This return is already ${held.status}.`, "CONFLICT");
5653
+ const store = reached.value;
5654
+ const connector = this.connectors.get(store.provider);
5655
+ if (!connector?.recordRefund) return PluginErr(`Returns are not available for ${store.provider} stores.`, "NOT_IMPLEMENTED");
5656
+ const [exported] = await this.db.select({ remoteOrderId: channelOrderExports.remoteOrderId }).from(channelOrderExports)
5657
+ .where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.orderId, held.orderId), eq(channelOrderExports.storeId, store.id)));
5658
+ if (!exported?.remoteOrderId) return PluginErr("This order never reached the store.", "NOT_FOUND");
5659
+
5660
+ const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, held.orderId));
5661
+ const priced: Array<{ lineItemId: string; quantity: number; variantId: string | null; amount: number }> = [];
5662
+ for (const line of held.lines) {
5663
+ const item = orderLines.find((candidate) => candidate.id === line.orderLineItemId);
5664
+ if (!item) return PluginErr(`Line ${line.orderLineItemId} is no longer on this order.`, "VALIDATION_FAILED");
5665
+ priced.push({ lineItemId: item.id, quantity: line.quantity, variantId: item.variantId, amount: Math.round((item.totalPrice + item.taxAmount - item.discountAmount) * line.quantity / item.quantity) });
5666
+ }
5667
+ const actor = createSystemActor(orgId);
5668
+ if (held.status === "requested") {
5669
+ const ordersService = this.services.orders as { refundLines(orderId: string, input: { lines: Array<{ lineItemId: string; quantity: number }>; reason?: string }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }> };
5670
+ const refunded = await ordersService.refundLines(held.orderId, { lines: priced.map(({ lineItemId, quantity }) => ({ lineItemId, quantity })), reason: `Return ${held.id}` }, actor);
5671
+ if (!refunded.ok) return PluginErr(refunded.error?.message ?? "The shopper could not be paid back.", "REFUND_FAILED");
5672
+ await this.db.update(channelReturns).set({ status: "approved", updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id)));
5673
+ }
5674
+
5675
+ const variantIds = priced.flatMap((line) => (line.variantId === null ? [] : [line.variantId]));
5676
+ const mapped = variantIds.length === 0 ? [] : await this.db.select({ variantId: channelEntityMap.variantId, externalId: channelEntityMap.externalId }).from(channelEntityMap)
5677
+ .where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.variantId, variantIds)));
5678
+ const storeLines: Array<{ externalVariantId: string; quantity: number; amount: number }> = [];
5679
+ for (const line of priced) {
5680
+ const externalId = mapped.find((entry) => entry.variantId === line.variantId)?.externalId;
5681
+ if (!externalId) return PluginErr("The shopper was paid back, but the store has no record of a returned line, so it was not booked there.", "CHANNEL_MAPPING_MISSING");
5682
+ storeLines.push({ externalVariantId: externalId, quantity: line.quantity, amount: line.amount });
5683
+ }
5684
+ const amount = storeLines.reduce((sum, line) => sum + line.amount, 0);
5685
+ const booked = await connector.recordRefund(store as ChannelStore, exported.remoteOrderId, { lines: storeLines, amount, reason: `Return: ${held.reason}`, restock: true });
5686
+ if (!booked.ok) return PluginErr(`The shopper was paid back, but the store did not record the refund (${booked.error.message}). Approve again to retry.`, "CHANNEL_REFUND_NOT_RECORDED");
5687
+ const lines = priced.map(({ lineItemId, quantity }) => ({ lineItemId, quantity }));
5688
+ await this.db.insert(channelRefundRequests)
5689
+ .values({ organizationId: orgId, storeId: store.id, orderId: held.orderId, remoteRefundId: booked.value.remoteRefundId, amount, lines, state: "executed", approvedBy: requireUserId(actor) })
5690
+ .onConflictDoUpdate({ target: [channelRefundRequests.storeId, channelRefundRequests.remoteRefundId], set: { amount, lines, state: "executed", updatedAt: new Date() } });
5691
+ const [closed] = await this.db.update(channelReturns).set({ status: "closed", updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id))).returning();
5692
+ return closed ? Ok(closed) : PluginErr("Return not found.", "NOT_FOUND");
5693
+ }
5694
+
5695
+ /** The merchant refuses a held return. Nothing moves. */
5696
+ async declineReturn(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturn>> {
5697
+ const [held] = await this.db.select().from(channelReturns).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, id)));
5698
+ if (!held || !held.remoteReturnId.startsWith(PLATFORM_RETURN_PREFIX)) return PluginErr("Return not found.", "NOT_FOUND");
5699
+ const reached = await this.reachableStore(orgId, held.storeId, context);
5700
+ if (!reached.ok) return PluginErr("Return not found.", "NOT_FOUND");
5701
+ const [declined] = await this.db.update(channelReturns).set({ status: "declined", updatedAt: new Date() })
5702
+ .where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id), eq(channelReturns.status, "requested"))).returning();
5703
+ return declined ? Ok(declined) : PluginErr(`This return is already ${held.status}.`, "CONFLICT");
5704
+ }
5705
+
5614
5706
  /** A cancelled or refunded order: nothing to push to a store, ever again. */
5615
5707
  async isOrderClosed(orgId: string, orderId: string): Promise<boolean> {
5616
5708
  const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));