@porulle/plugin-channel-connector 0.75.0 → 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")
package/dist/schema.d.ts CHANGED
@@ -2354,4 +2354,5 @@ export type ChannelCatalogPushEvent = typeof channelCatalogPushEvents.$inferSele
2354
2354
  export type ChannelOrderExport = typeof channelOrderExports.$inferSelect;
2355
2355
  export type ChannelExportEvent = typeof channelExportEvents.$inferSelect;
2356
2356
  export type ChannelRefundRequest = typeof channelRefundRequests.$inferSelect;
2357
+ export type ChannelReturn = typeof channelReturns.$inferSelect;
2357
2358
  export type ChannelRefundEvent = typeof channelRefundEvents.$inferSelect;
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;
@@ -4416,7 +4418,9 @@ export class ChannelConnectorService {
4416
4418
  if (!store || store.status !== "connected")
4417
4419
  return PluginErr("The store this order went to is not connected.", "NOT_FOUND");
4418
4420
  const connector = this.connectors.get(store.provider);
4419
- 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))
4420
4424
  return PluginErr(`Returns are not available for ${store.provider} stores.`, "NOT_IMPLEMENTED");
4421
4425
  const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
4422
4426
  const variantIds = lines.flatMap((line) => (line.variantId === null ? [] : [line.variantId]));
@@ -4436,14 +4440,18 @@ export class ChannelConnectorService {
4436
4440
  return PluginErr(`The store has no record of line ${wanted.orderLineItemId}, so it cannot take it back.`, "CHANNEL_MAPPING_MISSING");
4437
4441
  remote.push({ externalVariantId: externalId, quantity: wanted.quantity });
4438
4442
  }
4439
- const asked = await connector.requestReturn(store, exported.remoteOrderId, { lines: remote, reason: input.reason, ...(input.note ? { note: input.note } : {}) });
4440
- if (!asked.ok)
4441
- 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
+ }
4442
4450
  const [row] = await this.db.insert(channelReturns).values({
4443
4451
  organizationId: orgId,
4444
4452
  storeId: store.id,
4445
4453
  orderId,
4446
- remoteReturnId: asked.value.remoteReturnId,
4454
+ remoteReturnId,
4447
4455
  status: "requested",
4448
4456
  lines: input.lines,
4449
4457
  reason: input.reason,
@@ -4453,6 +4461,86 @@ export class ChannelConnectorService {
4453
4461
  return PluginErr("The return could not be recorded.");
4454
4462
  return Ok(row);
4455
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
+ }
4456
4544
  /** A cancelled or refunded order: nothing to push to a store, ever again. */
4457
4545
  async isOrderClosed(orgId, orderId) {
4458
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.75.0",
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.75.0"
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")
package/src/schema.ts CHANGED
@@ -328,4 +328,5 @@ export type ChannelCatalogPushEvent = typeof channelCatalogPushEvents.$inferSele
328
328
  export type ChannelOrderExport = typeof channelOrderExports.$inferSelect;
329
329
  export type ChannelExportEvent = typeof channelExportEvents.$inferSelect;
330
330
  export type ChannelRefundRequest = typeof channelRefundRequests.$inferSelect;
331
+ export type ChannelReturn = typeof channelReturns.$inferSelect;
331
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
 
@@ -5582,7 +5585,9 @@ export class ChannelConnectorService {
5582
5585
  const store = await this.getStoreRecord(orgId, exported.storeId);
5583
5586
  if (!store || store.status !== "connected") return PluginErr("The store this order went to is not connected.", "NOT_FOUND");
5584
5587
  const connector = this.connectors.get(store.provider);
5585
- 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");
5586
5591
 
5587
5592
  const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
5588
5593
  const variantIds = lines.flatMap((line) => (line.variantId === null ? [] : [line.variantId]));
@@ -5600,13 +5605,17 @@ export class ChannelConnectorService {
5600
5605
  remote.push({ externalVariantId: externalId, quantity: wanted.quantity });
5601
5606
  }
5602
5607
 
5603
- const asked = await connector.requestReturn(store as ChannelStore, exported.remoteOrderId, { lines: remote, reason: input.reason, ...(input.note ? { note: input.note } : {}) });
5604
- 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
+ }
5605
5614
  const [row] = await this.db.insert(channelReturns).values({
5606
5615
  organizationId: orgId,
5607
5616
  storeId: store.id,
5608
5617
  orderId,
5609
- remoteReturnId: asked.value.remoteReturnId,
5618
+ remoteReturnId,
5610
5619
  status: "requested",
5611
5620
  lines: input.lines,
5612
5621
  reason: input.reason,
@@ -5616,6 +5625,84 @@ export class ChannelConnectorService {
5616
5625
  return Ok(row);
5617
5626
  }
5618
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
+
5619
5706
  /** A cancelled or refunded order: nothing to push to a store, ever again. */
5620
5707
  async isOrderClosed(orgId: string, orderId: string): Promise<boolean> {
5621
5708
  const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));