@porulle/plugin-channel-connector 0.76.0 → 0.77.1

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, ChannelReturn, ConnectedStore, StoreHealth, } from "./schema.js";
30
+ export type { ChannelCatalogPush, ChannelCatalogPushEvent, ChannelCatalogConflict, ChannelCatalogConflictEvent, ChannelEntityMapEntry, ChannelExportEvent, ChannelOrderExport, ChannelRefundEvent, ChannelRefundRequest, ChannelReturn, ChannelReturnView, ConnectedStore, StoreHealth, } from "./schema.js";
31
31
  export declare function channelConnectorPlugin(options?: ChannelConnectorPluginOptions): import("@porulle/core").CommercePlugin;
package/dist/index.js CHANGED
@@ -739,7 +739,11 @@ export function channelConnectorPlugin(options = {}) {
739
739
  channels.post("/returns/{id}/approve")
740
740
  .summary("Approve a held return: pay the shopper back and book the refund at the store")
741
741
  .permission("channels:connect")
742
- .handler(async ({ params, orgId, actor, raw }) => unwrap(await service.approveReturn(orgId, params.id, { orgId, actor, raw })));
742
+ .input(z.object({ refundShipping: z.boolean().optional() }))
743
+ .handler(async ({ params, orgId, actor, raw, input }) => {
744
+ const options = z.object({ refundShipping: z.boolean().optional() }).catch({}).parse(input ?? {});
745
+ return unwrap(await service.approveReturn(orgId, params.id, { orgId, actor, raw }, options.refundShipping === undefined ? {} : { refundShipping: options.refundShipping }));
746
+ });
743
747
  channels.post("/returns/{id}/decline")
744
748
  .summary("Decline a held return")
745
749
  .permission("channels:connect")
@@ -74,6 +74,7 @@ export declare function mockChannelConnector(options?: MockChannelConnectorOptio
74
74
  quantity: number;
75
75
  }[];
76
76
  amount?: number;
77
+ shippingAmount?: number;
77
78
  } | {
78
79
  kind: "return.updated";
79
80
  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() })), amount: z.number().int().exactOptional() }),
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(), shippingAmount: 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
@@ -1929,6 +1929,23 @@ export declare const channelReturns: import("drizzle-orm/pg-core/table").PgTable
1929
1929
  quantity: number;
1930
1930
  }[];
1931
1931
  }>;
1932
+ shippingAmount: import("@porulle/core/drizzle").PgColumn<{
1933
+ name: "shipping_amount";
1934
+ tableName: "channel_returns";
1935
+ dataType: "number";
1936
+ columnType: "PgInteger";
1937
+ data: number;
1938
+ driverParam: string | number;
1939
+ notNull: true;
1940
+ hasDefault: true;
1941
+ isPrimaryKey: false;
1942
+ isAutoincrement: false;
1943
+ hasRuntimeDefault: false;
1944
+ enumValues: undefined;
1945
+ baseColumn: never;
1946
+ identity: undefined;
1947
+ generated: undefined;
1948
+ }, {}, {}>;
1932
1949
  reason: import("@porulle/core/drizzle").PgColumn<{
1933
1950
  name: "reason";
1934
1951
  tableName: "channel_returns";
@@ -2106,6 +2123,40 @@ export declare const channelRefundRequests: import("drizzle-orm/pg-core/table").
2106
2123
  identity: undefined;
2107
2124
  generated: undefined;
2108
2125
  }, {}, {}>;
2126
+ shippingAmount: import("@porulle/core/drizzle").PgColumn<{
2127
+ name: "shipping_amount";
2128
+ tableName: "channel_refund_requests";
2129
+ dataType: "number";
2130
+ columnType: "PgInteger";
2131
+ data: number;
2132
+ driverParam: string | number;
2133
+ notNull: true;
2134
+ hasDefault: true;
2135
+ isPrimaryKey: false;
2136
+ isAutoincrement: false;
2137
+ hasRuntimeDefault: false;
2138
+ enumValues: undefined;
2139
+ baseColumn: never;
2140
+ identity: undefined;
2141
+ generated: undefined;
2142
+ }, {}, {}>;
2143
+ adjustmentAmount: import("@porulle/core/drizzle").PgColumn<{
2144
+ name: "adjustment_amount";
2145
+ tableName: "channel_refund_requests";
2146
+ dataType: "number";
2147
+ columnType: "PgInteger";
2148
+ data: number;
2149
+ driverParam: string | number;
2150
+ notNull: true;
2151
+ hasDefault: true;
2152
+ isPrimaryKey: false;
2153
+ isAutoincrement: false;
2154
+ hasRuntimeDefault: false;
2155
+ enumValues: undefined;
2156
+ baseColumn: never;
2157
+ identity: undefined;
2158
+ generated: undefined;
2159
+ }, {}, {}>;
2109
2160
  lines: import("@porulle/core/drizzle").PgColumn<{
2110
2161
  name: "lines";
2111
2162
  tableName: "channel_refund_requests";
@@ -2355,4 +2406,15 @@ export type ChannelOrderExport = typeof channelOrderExports.$inferSelect;
2355
2406
  export type ChannelExportEvent = typeof channelExportEvents.$inferSelect;
2356
2407
  export type ChannelRefundRequest = typeof channelRefundRequests.$inferSelect;
2357
2408
  export type ChannelReturn = typeof channelReturns.$inferSelect;
2409
+ /** A held return as its merchant decides it: which order, which items, and how much delivery is refundable. */
2410
+ export type ChannelReturnView = ChannelReturn & {
2411
+ orderNumber: string | null;
2412
+ items: Array<{
2413
+ orderLineItemId: string;
2414
+ title: string;
2415
+ quantity: number;
2416
+ }>;
2417
+ /** Delivery not yet refunded on the order: what approving with delivery would pay back. */
2418
+ shippingRefundable: number;
2419
+ };
2358
2420
  export type ChannelRefundEvent = typeof channelRefundEvents.$inferSelect;
package/dist/schema.js CHANGED
@@ -184,6 +184,8 @@ export const channelReturns = pgTable("channel_returns", {
184
184
  remoteReturnId: text("remote_return_id").notNull(),
185
185
  status: text("status", { enum: ["requested", "approved", "declined", "closed", "cancelled"] }).notNull().default("requested"),
186
186
  lines: jsonb("lines").$type().notNull(),
187
+ /** Delivery the merchant refunded when approving a return held on the platform. */
188
+ shippingAmount: integer("shipping_amount").notNull().default(0),
187
189
  reason: text("reason").notNull(),
188
190
  note: text("note"),
189
191
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
@@ -198,7 +200,12 @@ export const channelRefundRequests = pgTable("channel_refund_requests", {
198
200
  storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
199
201
  orderId: uuid("order_id").notNull(),
200
202
  remoteRefundId: text("remote_refund_id").notNull(),
203
+ /** Everything the request pays back: its lines, `shippingAmount` and `adjustmentAmount`. */
201
204
  amount: integer("amount").notNull(),
205
+ /** Of `amount`, delivery the store refunded. */
206
+ shippingAmount: integer("shipping_amount").notNull().default(0),
207
+ /** Of `amount`, money the store refunded with no line behind it (goodwill). */
208
+ adjustmentAmount: integer("adjustment_amount").notNull().default(0),
202
209
  /** The order lines the store refunded. Null on requests made before it was kept. */
203
210
  lines: jsonb("lines").$type(),
204
211
  state: text("state", { enum: ["requested", "approved", "rejected", "executed"] }).notNull().default("requested"),
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 ChannelReturn, type ConnectedStore } from "./schema.js";
6
+ import { type ChannelCatalogPush, type ChannelCatalogConflict, type ChannelOrderExport, type ChannelRefundRequest, type ChannelReturn, type ChannelReturnView, 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"];
@@ -712,7 +712,10 @@ export declare class ChannelConnectorService {
712
712
  private setInventoryLevel;
713
713
  private createRefundRequest;
714
714
  private executeRefund;
715
- listRefundRequests(orgId: string): Promise<PluginResult<ChannelRefundRequest[]>>;
715
+ /** Held refunds, each with the order number an approver knows the order by. */
716
+ listRefundRequests(orgId: string): Promise<PluginResult<Array<ChannelRefundRequest & {
717
+ orderNumber: string | null;
718
+ }>>>;
716
719
  approveRefund(orgId: string, id: string, actor: {
717
720
  userId: string;
718
721
  }): Promise<PluginResult<ChannelRefundRequest>>;
@@ -755,14 +758,17 @@ export declare class ChannelConnectorService {
755
758
  status: string;
756
759
  }>>;
757
760
  /** 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[]>>;
761
+ listReturns(orgId: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturnView[]>>;
759
762
  /**
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.
763
+ * The merchant takes a held return back: the shopper is paid back those lines (and the delivery, when
764
+ * the merchant refunds it), then the refund is booked at the store with the stock put back, and kept
765
+ * as an executed refund request under the store's own refund id so the store's webhook for it pays
766
+ * nobody twice. If the store will not book it, the shopper has still been paid and the return stays
767
+ * `approved`; approving again only books it, with the delivery decided the first time.
764
768
  */
765
- approveReturn(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturn>>;
769
+ approveReturn(orgId: string, id: string, context?: StoreReadContext, options?: {
770
+ refundShipping?: boolean;
771
+ }): Promise<PluginResult<ChannelReturn>>;
766
772
  /** The merchant refuses a held return. Nothing moves. */
767
773
  declineReturn(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturn>>;
768
774
  /** A cancelled or refunded order: nothing to push to a store, ever again. */
package/dist/service.js CHANGED
@@ -5,7 +5,7 @@ import { isValidFieldPath, requireUserId } from "@porulle/core";
5
5
  import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
6
6
  import { resolveLiveCredentials, withLiveCredentials } from "./live-credentials.js";
7
7
  import { and, desc, eq, inArray, isNull, lte, or, sql } from "@porulle/core/drizzle";
8
- import { brands, categories, customerAddresses, customers, entityBrands, entityCategories, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, fulfillmentLineItems, fulfillmentRecords, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
8
+ import { brands, categories, customerAddresses, customers, entityBrands, entityCategories, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, fulfillmentLineItems, fulfillmentRecords, orderLineItems, orderRefunds, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
9
9
  import { planAbsentArchives } from "./deletion-policy.js";
10
10
  import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityLinks, channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, channelReturns, } from "./schema.js";
11
11
  import { mergeCatalogFieldMapping, normalizeCatalogFieldMapping, selectCatalogFieldMapping, } from "./catalog-field-mapping.js";
@@ -4176,14 +4176,15 @@ export class ChannelConnectorService {
4176
4176
  const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
4177
4177
  const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id)));
4178
4178
  const refundLines = [];
4179
- let clean = event.lines.length > 0;
4179
+ // Every line the store named is one of ours, with that much still refundable.
4180
+ let mapped = true;
4180
4181
  for (const line of event.lines) {
4181
4182
  const externalId = line.externalVariantId;
4182
4183
  const quantity = line.quantity;
4183
4184
  const mapping = mappings.find((item) => item.externalId === externalId);
4184
4185
  const orderLine = mapping ? orderLines.find((item) => item.variantId === mapping.variantId || item.entityId === mapping.entityId) : undefined;
4185
4186
  if (!orderLine || !Number.isInteger(quantity) || quantity < 1 || quantity > orderLine.quantity - orderLine.refundedQuantity)
4186
- clean = false;
4187
+ mapped = false;
4187
4188
  else
4188
4189
  refundLines.push({ lineItemId: orderLine.id, quantity });
4189
4190
  }
@@ -4191,17 +4192,33 @@ export class ChannelConnectorService {
4191
4192
  const item = orderLines.find((candidate) => candidate.id === line.lineItemId);
4192
4193
  return sum + Math.round((item.totalPrice + item.taxAmount - item.discountAmount) * line.quantity / item.quantity);
4193
4194
  }, 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);
4197
4195
  const [order] = await this.db.select().from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
4198
4196
  if (!order)
4199
4197
  return PluginErr("Order not found.", "NOT_FOUND");
4198
+ const completed = (await this.db.select({ amount: orderRefunds.amount, shippingAmount: orderRefunds.shippingAmount }).from(orderRefunds)
4199
+ .where(and(eq(orderRefunds.orderId, orderId), eq(orderRefunds.status, "completed"))));
4200
+ const shippingLeft = Math.max(0, order.shippingTotal - completed.reduce((sum, refund) => sum + refund.shippingAmount, 0));
4201
+ const orderLeft = Math.max(0, order.grandTotal - completed.reduce((sum, refund) => sum + refund.amount, 0));
4202
+ // What the store refunded, and never more than the shopper paid for what it names: the lines at most
4203
+ // at the platform's own price (part of a line, or a line the store discounted, is less), the delivery
4204
+ // at most what of it is not refunded yet, money with no line behind it (goodwill) only when the refund
4205
+ // names no line, and the whole at most what the order has left.
4206
+ const storeShipping = Math.max(0, event.shippingAmount ?? 0);
4207
+ const shippingAmount = Math.min(storeShipping, shippingLeft);
4208
+ const rest = event.amount === undefined ? priced : Math.max(0, event.amount - storeShipping);
4209
+ const linesAmount = Math.min(rest, priced);
4210
+ const goodwill = event.lines.length === 0 ? rest : 0;
4211
+ const adjustmentAmount = Math.max(0, Math.min(goodwill, orderLeft - linesAmount - shippingAmount));
4212
+ const amount = Math.min(linesAmount + shippingAmount + adjustmentAmount, orderLeft);
4213
+ // A refund that pays nothing (a restock, or nothing left to pay) asks nobody for money.
4214
+ if (amount === 0)
4215
+ return Ok(null);
4200
4216
  const max = this.options.refundAutoMax ?? order.amountCaptured ?? order.grandTotal;
4201
4217
  const ageOk = Date.now() - store.createdAt.getTime() >= (this.options.newStoreDays ?? 7) * 86_400_000;
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();
4218
+ // Only whole lines, at exactly the platform's price with exactly their delivery, are automatic; any
4219
+ // other amount is a person's call.
4220
+ const auto = mapped && refundLines.length > 0 && adjustmentAmount === 0 && linesAmount === priced && shippingAmount === storeShipping && ageOk && amount <= max;
4221
+ const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, shippingAmount, adjustmentAmount, lines: mapped ? refundLines : null, state: auto ? "approved" : "requested", approvedBy: auto ? requireUserId(actor) : null }).returning();
4205
4222
  const request = rows[0];
4206
4223
  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) });
4207
4224
  if (auto) {
@@ -4217,16 +4234,28 @@ export class ChannelConnectorService {
4217
4234
  }
4218
4235
  async executeRefund(request, lines, actor) {
4219
4236
  const ordersService = this.services.orders;
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);
4237
+ // A request that kept its lines pays back its own amount for them; an older one is priced from the
4238
+ // lines rebuilt for it. Delivery and goodwill ride beside the lines.
4239
+ const linesAmount = request.amount - request.shippingAmount - request.adjustmentAmount;
4240
+ const result = await ordersService.refundLines(request.orderId, {
4241
+ lines,
4242
+ reason: `Channel refund ${request.remoteRefundId}`,
4243
+ ...(request.lines && lines.length > 0 ? { amount: linesAmount } : {}),
4244
+ ...(request.shippingAmount > 0 ? { shippingAmount: request.shippingAmount } : {}),
4245
+ ...(request.adjustmentAmount > 0 ? { adjustmentAmount: request.adjustmentAmount } : {}),
4246
+ }, actor);
4222
4247
  if (!result.ok)
4223
4248
  return PluginErr(result.error?.message ?? "Refund execution failed.");
4224
4249
  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
4250
  await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: requireUserId(actor) });
4226
4251
  return Ok(updated);
4227
4252
  }
4253
+ /** Held refunds, each with the order number an approver knows the order by. */
4228
4254
  async listRefundRequests(orgId) {
4229
- return Ok(await this.db.select().from(channelRefundRequests).where(and(eq(channelRefundRequests.organizationId, orgId), eq(channelRefundRequests.state, "requested"))));
4255
+ const rows = await this.db.select({ request: channelRefundRequests, orderNumber: orders.orderNumber }).from(channelRefundRequests)
4256
+ .leftJoin(orders, eq(orders.id, channelRefundRequests.orderId))
4257
+ .where(and(eq(channelRefundRequests.organizationId, orgId), eq(channelRefundRequests.state, "requested")));
4258
+ return Ok(rows.map((row) => ({ ...row.request, orderNumber: row.orderNumber })));
4230
4259
  }
4231
4260
  async approveRefund(orgId, id, actor) {
4232
4261
  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();
@@ -4467,15 +4496,33 @@ export class ChannelConnectorService {
4467
4496
  if (allowed !== null && allowed.length === 0)
4468
4497
  return Ok([]);
4469
4498
  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);
4499
+ if (rows.length === 0)
4500
+ return Ok([]);
4501
+ const orderIds = [...new Set(rows.map((row) => row.orderId))];
4502
+ const orderRows = await this.db.select({ id: orders.id, orderNumber: orders.orderNumber, shippingTotal: orders.shippingTotal }).from(orders)
4503
+ .where(and(eq(orders.organizationId, orgId), inArray(orders.id, orderIds)));
4504
+ const lineRows = await this.db.select({ id: orderLineItems.id, title: orderLineItems.title }).from(orderLineItems).where(inArray(orderLineItems.orderId, orderIds));
4505
+ const refunded = await this.db.select({ orderId: orderRefunds.orderId, shippingAmount: orderRefunds.shippingAmount }).from(orderRefunds)
4506
+ .where(and(inArray(orderRefunds.orderId, orderIds), eq(orderRefunds.status, "completed")));
4507
+ return Ok(rows.map((row) => {
4508
+ const order = orderRows.find((candidate) => candidate.id === row.orderId);
4509
+ const shippingRefunded = refunded.filter((refund) => refund.orderId === row.orderId).reduce((sum, refund) => sum + refund.shippingAmount, 0);
4510
+ return {
4511
+ ...row,
4512
+ orderNumber: order?.orderNumber ?? null,
4513
+ items: row.lines.map((line) => ({ orderLineItemId: line.orderLineItemId, title: lineRows.find((candidate) => candidate.id === line.orderLineItemId)?.title ?? "Item", quantity: line.quantity })),
4514
+ shippingRefundable: Math.max(0, (order?.shippingTotal ?? 0) - shippingRefunded),
4515
+ };
4516
+ }));
4471
4517
  }
4472
4518
  /**
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.
4519
+ * The merchant takes a held return back: the shopper is paid back those lines (and the delivery, when
4520
+ * the merchant refunds it), then the refund is booked at the store with the stock put back, and kept
4521
+ * as an executed refund request under the store's own refund id so the store's webhook for it pays
4522
+ * nobody twice. If the store will not book it, the shopper has still been paid and the return stays
4523
+ * `approved`; approving again only books it, with the delivery decided the first time.
4477
4524
  */
4478
- async approveReturn(orgId, id, context) {
4525
+ async approveReturn(orgId, id, context, options = {}) {
4479
4526
  const [held] = await this.db.select().from(channelReturns).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, id)));
4480
4527
  if (!held || !held.remoteReturnId.startsWith(PLATFORM_RETURN_PREFIX))
4481
4528
  return PluginErr("Return not found.", "NOT_FOUND");
@@ -4501,12 +4548,19 @@ export class ChannelConnectorService {
4501
4548
  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
4549
  }
4503
4550
  const actor = createSystemActor(orgId);
4551
+ let shippingAmount = held.shippingAmount;
4504
4552
  if (held.status === "requested") {
4553
+ if (options.refundShipping === true) {
4554
+ const [order] = await this.db.select({ shippingTotal: orders.shippingTotal }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, held.orderId)));
4555
+ const refunded = await this.db.select({ shippingAmount: orderRefunds.shippingAmount }).from(orderRefunds)
4556
+ .where(and(eq(orderRefunds.orderId, held.orderId), eq(orderRefunds.status, "completed")));
4557
+ shippingAmount = Math.max(0, (order?.shippingTotal ?? 0) - refunded.reduce((sum, refund) => sum + refund.shippingAmount, 0));
4558
+ }
4505
4559
  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);
4560
+ const refunded = await ordersService.refundLines(held.orderId, { lines: priced.map(({ lineItemId, quantity }) => ({ lineItemId, quantity })), reason: `Return ${held.id}`, ...(shippingAmount > 0 ? { shippingAmount } : {}) }, actor);
4507
4561
  if (!refunded.ok)
4508
4562
  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)));
4563
+ await this.db.update(channelReturns).set({ status: "approved", shippingAmount, updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id)));
4510
4564
  }
4511
4565
  const variantIds = priced.flatMap((line) => (line.variantId === null ? [] : [line.variantId]));
4512
4566
  const mapped = variantIds.length === 0 ? [] : await this.db.select({ variantId: channelEntityMap.variantId, externalId: channelEntityMap.externalId }).from(channelEntityMap)
@@ -4518,14 +4572,14 @@ export class ChannelConnectorService {
4518
4572
  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
4573
  storeLines.push({ externalVariantId: externalId, quantity: line.quantity, amount: line.amount });
4520
4574
  }
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 });
4575
+ const amount = storeLines.reduce((sum, line) => sum + line.amount, 0) + shippingAmount;
4576
+ const booked = await connector.recordRefund(store, exported.remoteOrderId, { lines: storeLines, amount, ...(shippingAmount > 0 ? { shippingAmount } : {}), reason: `Return: ${held.reason}`, restock: true });
4523
4577
  if (!booked.ok)
4524
4578
  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
4579
  const lines = priced.map(({ lineItemId, quantity }) => ({ lineItemId, quantity }));
4526
4580
  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() } });
4581
+ .values({ organizationId: orgId, storeId: store.id, orderId: held.orderId, remoteRefundId: booked.value.remoteRefundId, amount, shippingAmount, lines, state: "executed", approvedBy: requireUserId(actor) })
4582
+ .onConflictDoUpdate({ target: [channelRefundRequests.storeId, channelRefundRequests.remoteRefundId], set: { amount, shippingAmount, adjustmentAmount: 0, lines, state: "executed", updatedAt: new Date() } });
4529
4583
  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
4584
  return closed ? Ok(closed) : PluginErr("Return not found.", "NOT_FOUND");
4531
4585
  }
@@ -4591,8 +4645,10 @@ export class ChannelConnectorService {
4591
4645
  if (!remoteStatus.ok) {
4592
4646
  return this.transitionExport(orgId, created.value.id, "failed", requireUserId(actor), remoteStatus.error.message, remoteStatus.error.retriable === true ? "transient" : "definitive");
4593
4647
  }
4594
- if (remoteStatus.value.status === "confirmed") {
4595
- return this.transitionExport(orgId, created.value.id, "confirmed", requireUserId(actor), "Remote order confirmed.");
4648
+ // `fulfilled` is a received order too: WooCommerce completes virtual and downloadable orders on
4649
+ // arrival, and an export waiting for `confirmed` would wait forever.
4650
+ if (remoteStatus.value.status === "confirmed" || remoteStatus.value.status === "fulfilled") {
4651
+ return this.transitionExport(orgId, created.value.id, "confirmed", requireUserId(actor), remoteStatus.value.status === "fulfilled" ? "Remote order received and completed by the store." : "Remote order confirmed.");
4596
4652
  }
4597
4653
  if (remoteStatus.value.status === "failed" || remoteStatus.value.status === "cancelled") {
4598
4654
  return this.transitionExport(orgId, created.value.id, "failed", requireUserId(actor), `Remote order status: ${remoteStatus.value.status}.`);
@@ -4614,7 +4670,7 @@ export class ChannelConnectorService {
4614
4670
  const mapping = (line.variantId && mappings.find((item) => item.kind === "variant" && item.variantId === line.variantId)) ?? mappings.find((item) => item.kind === "entity" && item.entityId === line.entityId);
4615
4671
  if (!mapping)
4616
4672
  return PluginErr(`External mapping is missing for order line ${line.id}.`, "MAPPING_MISSING");
4617
- lines.push({ externalVariantId: mapping.externalId, ...(line.sku ? { sku: line.sku } : {}), title: line.title, quantity: line.quantity, unitPrice: line.unitPrice, totalPrice: line.totalPrice });
4673
+ lines.push({ externalVariantId: mapping.externalId, ...(line.sku ? { sku: line.sku } : {}), title: line.title, quantity: line.quantity, unitPrice: line.unitPrice, totalPrice: line.totalPrice, ...(line.discountAmount > 0 ? { discountAmount: line.discountAmount } : {}) });
4618
4674
  }
4619
4675
  let email = null;
4620
4676
  let name = "";
@@ -4663,7 +4719,9 @@ export class ChannelConnectorService {
4663
4719
  // ponytail: like delivery, a discount is sent only with the whole order; apportion it when multi-store orders exist.
4664
4720
  const discountCode = typeof metadata.promotionCode === "string" && metadata.promotionCode.trim() !== "" ? metadata.promotionCode.trim() : "DISCOUNT";
4665
4721
  const discount = selected.length === lineItems.length && order.discountTotal > 0 ? { code: discountCode, amount: order.discountTotal } : null;
4666
- return Ok({ orderId, currency: order.currency, grandTotal: linesTotal + (shipping?.amount ?? 0) - (discount?.amount ?? 0), lines, ...(shipping ? { shipping } : {}), ...(discount ? { discount } : {}), customer: { name, email, shippingAddress } });
4722
+ // A line's discount travels with the order discount it is a share of, never without it.
4723
+ const slicedLines = discount ? lines : lines.map(({ discountAmount: _share, ...line }) => line);
4724
+ return Ok({ orderId, currency: order.currency, grandTotal: linesTotal + (shipping?.amount ?? 0) - (discount?.amount ?? 0), lines: slicedLines, ...(shipping ? { shipping } : {}), ...(discount ? { discount } : {}), customer: { name, email, shippingAddress } });
4667
4725
  }
4668
4726
  async reapExports(input) {
4669
4727
  const now = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/plugin-channel-connector",
3
- "version": "0.76.0",
3
+ "version": "0.77.1",
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.76.0"
25
+ "@porulle/core": "0.77.1"
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/eslint-config": "0.1.0",
33
- "@porulle/typescript-config": "0.1.0"
32
+ "@porulle/typescript-config": "0.1.0",
33
+ "@porulle/eslint-config": "0.1.0"
34
34
  },
35
35
  "publishConfig": {
36
36
  "access": "public"
package/src/index.ts CHANGED
@@ -260,6 +260,7 @@ export type {
260
260
  ChannelRefundEvent,
261
261
  ChannelRefundRequest,
262
262
  ChannelReturn,
263
+ ChannelReturnView,
263
264
  ConnectedStore,
264
265
  StoreHealth,
265
266
  } from "./schema.js";
@@ -918,7 +919,11 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
918
919
  channels.post("/returns/{id}/approve")
919
920
  .summary("Approve a held return: pay the shopper back and book the refund at the store")
920
921
  .permission("channels:connect")
921
- .handler(async ({ params, orgId, actor, raw }: ChannelRouteContext) => unwrap(await service.approveReturn(orgId, params.id!, { orgId, actor, raw })));
922
+ .input(z.object({ refundShipping: z.boolean().optional() }))
923
+ .handler(async ({ params, orgId, actor, raw, input }: ChannelRouteContext) => {
924
+ const options = z.object({ refundShipping: z.boolean().optional() }).catch({}).parse(input ?? {});
925
+ return unwrap(await service.approveReturn(orgId, params.id!, { orgId, actor, raw }, options.refundShipping === undefined ? {} : { refundShipping: options.refundShipping }));
926
+ });
922
927
 
923
928
  channels.post("/returns/{id}/decline")
924
929
  .summary("Decline a held return")
@@ -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() })), amount: z.number().int().exactOptional() }),
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(), shippingAmount: 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
@@ -267,6 +267,8 @@ export const channelReturns = pgTable(
267
267
  remoteReturnId: text("remote_return_id").notNull(),
268
268
  status: text("status", { enum: ["requested", "approved", "declined", "closed", "cancelled"] }).notNull().default("requested"),
269
269
  lines: jsonb("lines").$type<Array<{ orderLineItemId: string; quantity: number }>>().notNull(),
270
+ /** Delivery the merchant refunded when approving a return held on the platform. */
271
+ shippingAmount: integer("shipping_amount").notNull().default(0),
270
272
  reason: text("reason").notNull(),
271
273
  note: text("note"),
272
274
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
@@ -286,7 +288,12 @@ export const channelRefundRequests = pgTable(
286
288
  storeId: uuid("store_id").references(() => connectedStores.id, { onDelete: "cascade" }).notNull(),
287
289
  orderId: uuid("order_id").notNull(),
288
290
  remoteRefundId: text("remote_refund_id").notNull(),
291
+ /** Everything the request pays back: its lines, `shippingAmount` and `adjustmentAmount`. */
289
292
  amount: integer("amount").notNull(),
293
+ /** Of `amount`, delivery the store refunded. */
294
+ shippingAmount: integer("shipping_amount").notNull().default(0),
295
+ /** Of `amount`, money the store refunded with no line behind it (goodwill). */
296
+ adjustmentAmount: integer("adjustment_amount").notNull().default(0),
290
297
  /** The order lines the store refunded. Null on requests made before it was kept. */
291
298
  lines: jsonb("lines").$type<Array<{ lineItemId: string; quantity: number }>>(),
292
299
  state: text("state", { enum: ["requested", "approved", "rejected", "executed"] }).notNull().default("requested"),
@@ -329,4 +336,11 @@ export type ChannelOrderExport = typeof channelOrderExports.$inferSelect;
329
336
  export type ChannelExportEvent = typeof channelExportEvents.$inferSelect;
330
337
  export type ChannelRefundRequest = typeof channelRefundRequests.$inferSelect;
331
338
  export type ChannelReturn = typeof channelReturns.$inferSelect;
339
+ /** A held return as its merchant decides it: which order, which items, and how much delivery is refundable. */
340
+ export type ChannelReturnView = ChannelReturn & {
341
+ orderNumber: string | null;
342
+ items: Array<{ orderLineItemId: string; title: string; quantity: number }>;
343
+ /** Delivery not yet refunded on the order: what approving with delivery would pay back. */
344
+ shippingRefundable: number;
345
+ };
332
346
  export type ChannelRefundEvent = typeof channelRefundEvents.$inferSelect;
package/src/service.ts CHANGED
@@ -64,6 +64,7 @@ import {
64
64
  fulfillmentLineItems,
65
65
  fulfillmentRecords,
66
66
  orderLineItems,
67
+ orderRefunds,
67
68
  orders,
68
69
  prices,
69
70
  sellableAttributes,
@@ -95,6 +96,7 @@ import {
95
96
  type ChannelOrderExport,
96
97
  type ChannelRefundRequest,
97
98
  type ChannelReturn,
99
+ type ChannelReturnView,
98
100
  type ConnectedStore,
99
101
  } from "./schema.js";
100
102
  import type { StoreHealth } from "./schema.js";
@@ -5321,29 +5323,45 @@ export class ChannelConnectorService {
5321
5323
  const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
5322
5324
  const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id)));
5323
5325
  const refundLines: Array<{ lineItemId: string; quantity: number }> = [];
5324
- let clean = event.lines.length > 0;
5326
+ // Every line the store named is one of ours, with that much still refundable.
5327
+ let mapped = true;
5325
5328
  for (const line of event.lines) {
5326
5329
  const externalId = line.externalVariantId;
5327
5330
  const quantity = line.quantity;
5328
5331
  const mapping = mappings.find((item) => item.externalId === externalId);
5329
5332
  const orderLine = mapping ? orderLines.find((item) => item.variantId === mapping.variantId || item.entityId === mapping.entityId) : undefined;
5330
- if (!orderLine || !Number.isInteger(quantity) || quantity < 1 || quantity > orderLine.quantity - orderLine.refundedQuantity) clean = false;
5333
+ if (!orderLine || !Number.isInteger(quantity) || quantity < 1 || quantity > orderLine.quantity - orderLine.refundedQuantity) mapped = false;
5331
5334
  else refundLines.push({ lineItemId: orderLine.id, quantity });
5332
5335
  }
5333
5336
  const priced = refundLines.reduce((sum, line) => {
5334
5337
  const item = orderLines.find((candidate) => candidate.id === line.lineItemId)!;
5335
5338
  return sum + Math.round((item.totalPrice + item.taxAmount - item.discountAmount) * line.quantity / item.quantity);
5336
5339
  }, 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);
5340
5340
  const [order] = await this.db.select().from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
5341
5341
  if (!order) return PluginErr("Order not found.", "NOT_FOUND");
5342
+ const completed = (await this.db.select({ amount: orderRefunds.amount, shippingAmount: orderRefunds.shippingAmount }).from(orderRefunds)
5343
+ .where(and(eq(orderRefunds.orderId, orderId), eq(orderRefunds.status, "completed"))));
5344
+ const shippingLeft = Math.max(0, order.shippingTotal - completed.reduce((sum, refund) => sum + refund.shippingAmount, 0));
5345
+ const orderLeft = Math.max(0, order.grandTotal - completed.reduce((sum, refund) => sum + refund.amount, 0));
5346
+ // What the store refunded, and never more than the shopper paid for what it names: the lines at most
5347
+ // at the platform's own price (part of a line, or a line the store discounted, is less), the delivery
5348
+ // at most what of it is not refunded yet, money with no line behind it (goodwill) only when the refund
5349
+ // names no line, and the whole at most what the order has left.
5350
+ const storeShipping = Math.max(0, event.shippingAmount ?? 0);
5351
+ const shippingAmount = Math.min(storeShipping, shippingLeft);
5352
+ const rest = event.amount === undefined ? priced : Math.max(0, event.amount - storeShipping);
5353
+ const linesAmount = Math.min(rest, priced);
5354
+ const goodwill = event.lines.length === 0 ? rest : 0;
5355
+ const adjustmentAmount = Math.max(0, Math.min(goodwill, orderLeft - linesAmount - shippingAmount));
5356
+ const amount = Math.min(linesAmount + shippingAmount + adjustmentAmount, orderLeft);
5357
+ // A refund that pays nothing (a restock, or nothing left to pay) asks nobody for money.
5358
+ if (amount === 0) return Ok(null);
5342
5359
  const max = this.options.refundAutoMax ?? order.amountCaptured ?? order.grandTotal;
5343
5360
  const ageOk = Date.now() - store.createdAt.getTime() >= (this.options.newStoreDays ?? 7) * 86_400_000;
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();
5361
+ // Only whole lines, at exactly the platform's price with exactly their delivery, are automatic; any
5362
+ // other amount is a person's call.
5363
+ const auto = mapped && refundLines.length > 0 && adjustmentAmount === 0 && linesAmount === priced && shippingAmount === storeShipping && ageOk && amount <= max;
5364
+ const rows = await this.db.insert(channelRefundRequests).values({ organizationId: orgId, storeId: store.id, orderId, remoteRefundId, amount, shippingAmount, adjustmentAmount, lines: mapped ? refundLines : null, state: auto ? "approved" : "requested", approvedBy: auto ? requireUserId(actor) : null }).returning();
5347
5365
  const request = rows[0] as ChannelRefundRequest;
5348
5366
  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) });
5349
5367
  if (auto) {
@@ -5359,17 +5377,29 @@ export class ChannelConnectorService {
5359
5377
  }
5360
5378
 
5361
5379
  private async executeRefund(request: ChannelRefundRequest, lines: Array<{ lineItemId: string; quantity: number }>, actor: Actor): Promise<PluginResult<ChannelRefundRequest>> {
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);
5380
+ const ordersService = this.services.orders as { refundLines(orderId: string, input: { lines: Array<{ lineItemId: string; quantity: number }>; reason?: string; amount?: number; shippingAmount?: number; adjustmentAmount?: number }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }> };
5381
+ // A request that kept its lines pays back its own amount for them; an older one is priced from the
5382
+ // lines rebuilt for it. Delivery and goodwill ride beside the lines.
5383
+ const linesAmount = request.amount - request.shippingAmount - request.adjustmentAmount;
5384
+ const result = await ordersService.refundLines(request.orderId, {
5385
+ lines,
5386
+ reason: `Channel refund ${request.remoteRefundId}`,
5387
+ ...(request.lines && lines.length > 0 ? { amount: linesAmount } : {}),
5388
+ ...(request.shippingAmount > 0 ? { shippingAmount: request.shippingAmount } : {}),
5389
+ ...(request.adjustmentAmount > 0 ? { adjustmentAmount: request.adjustmentAmount } : {}),
5390
+ }, actor);
5365
5391
  if (!result.ok) return PluginErr(result.error?.message ?? "Refund execution failed.");
5366
5392
  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();
5367
5393
  await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: requireUserId(actor) });
5368
5394
  return Ok(updated as ChannelRefundRequest);
5369
5395
  }
5370
5396
 
5371
- async listRefundRequests(orgId: string): Promise<PluginResult<ChannelRefundRequest[]>> {
5372
- return Ok(await this.db.select().from(channelRefundRequests).where(and(eq(channelRefundRequests.organizationId, orgId), eq(channelRefundRequests.state, "requested"))) as ChannelRefundRequest[]);
5397
+ /** Held refunds, each with the order number an approver knows the order by. */
5398
+ async listRefundRequests(orgId: string): Promise<PluginResult<Array<ChannelRefundRequest & { orderNumber: string | null }>>> {
5399
+ const rows = await this.db.select({ request: channelRefundRequests, orderNumber: orders.orderNumber }).from(channelRefundRequests)
5400
+ .leftJoin(orders, eq(orders.id, channelRefundRequests.orderId))
5401
+ .where(and(eq(channelRefundRequests.organizationId, orgId), eq(channelRefundRequests.state, "requested")));
5402
+ return Ok(rows.map((row) => ({ ...(row.request as ChannelRefundRequest), orderNumber: row.orderNumber })));
5373
5403
  }
5374
5404
 
5375
5405
  async approveRefund(orgId: string, id: string, actor: { userId: string }): Promise<PluginResult<ChannelRefundRequest>> {
@@ -5626,7 +5656,7 @@ export class ChannelConnectorService {
5626
5656
  }
5627
5657
 
5628
5658
  /** 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[]>> {
5659
+ async listReturns(orgId: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturnView[]>> {
5630
5660
  const allowed = await this.allowedStores(orgId, context);
5631
5661
  if (allowed !== null && allowed.length === 0) return Ok([]);
5632
5662
  const rows = await this.db.select().from(channelReturns).where(and(
@@ -5635,16 +5665,33 @@ export class ChannelConnectorService {
5635
5665
  sql`${channelReturns.remoteReturnId} like ${`${PLATFORM_RETURN_PREFIX}%`}`,
5636
5666
  ...(allowed === null ? [] : [inArray(channelReturns.storeId, [...allowed])]),
5637
5667
  )).orderBy(desc(channelReturns.createdAt));
5638
- return Ok(rows);
5668
+ if (rows.length === 0) return Ok([]);
5669
+ const orderIds = [...new Set(rows.map((row) => row.orderId))];
5670
+ const orderRows = await this.db.select({ id: orders.id, orderNumber: orders.orderNumber, shippingTotal: orders.shippingTotal }).from(orders)
5671
+ .where(and(eq(orders.organizationId, orgId), inArray(orders.id, orderIds)));
5672
+ const lineRows = await this.db.select({ id: orderLineItems.id, title: orderLineItems.title }).from(orderLineItems).where(inArray(orderLineItems.orderId, orderIds));
5673
+ const refunded = await this.db.select({ orderId: orderRefunds.orderId, shippingAmount: orderRefunds.shippingAmount }).from(orderRefunds)
5674
+ .where(and(inArray(orderRefunds.orderId, orderIds), eq(orderRefunds.status, "completed")));
5675
+ return Ok(rows.map((row) => {
5676
+ const order = orderRows.find((candidate) => candidate.id === row.orderId);
5677
+ const shippingRefunded = refunded.filter((refund) => refund.orderId === row.orderId).reduce((sum, refund) => sum + refund.shippingAmount, 0);
5678
+ return {
5679
+ ...row,
5680
+ orderNumber: order?.orderNumber ?? null,
5681
+ items: row.lines.map((line) => ({ orderLineItemId: line.orderLineItemId, title: lineRows.find((candidate) => candidate.id === line.orderLineItemId)?.title ?? "Item", quantity: line.quantity })),
5682
+ shippingRefundable: Math.max(0, (order?.shippingTotal ?? 0) - shippingRefunded),
5683
+ };
5684
+ }));
5639
5685
  }
5640
5686
 
5641
5687
  /**
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.
5688
+ * The merchant takes a held return back: the shopper is paid back those lines (and the delivery, when
5689
+ * the merchant refunds it), then the refund is booked at the store with the stock put back, and kept
5690
+ * as an executed refund request under the store's own refund id so the store's webhook for it pays
5691
+ * nobody twice. If the store will not book it, the shopper has still been paid and the return stays
5692
+ * `approved`; approving again only books it, with the delivery decided the first time.
5646
5693
  */
5647
- async approveReturn(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturn>> {
5694
+ async approveReturn(orgId: string, id: string, context?: StoreReadContext, options: { refundShipping?: boolean } = {}): Promise<PluginResult<ChannelReturn>> {
5648
5695
  const [held] = await this.db.select().from(channelReturns).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, id)));
5649
5696
  if (!held || !held.remoteReturnId.startsWith(PLATFORM_RETURN_PREFIX)) return PluginErr("Return not found.", "NOT_FOUND");
5650
5697
  const reached = await this.reachableStore(orgId, held.storeId, context);
@@ -5665,11 +5712,18 @@ export class ChannelConnectorService {
5665
5712
  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
5713
  }
5667
5714
  const actor = createSystemActor(orgId);
5715
+ let shippingAmount = held.shippingAmount;
5668
5716
  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);
5717
+ if (options.refundShipping === true) {
5718
+ const [order] = await this.db.select({ shippingTotal: orders.shippingTotal }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, held.orderId)));
5719
+ const refunded = await this.db.select({ shippingAmount: orderRefunds.shippingAmount }).from(orderRefunds)
5720
+ .where(and(eq(orderRefunds.orderId, held.orderId), eq(orderRefunds.status, "completed")));
5721
+ shippingAmount = Math.max(0, (order?.shippingTotal ?? 0) - refunded.reduce((sum, refund) => sum + refund.shippingAmount, 0));
5722
+ }
5723
+ const ordersService = this.services.orders as { refundLines(orderId: string, input: { lines: Array<{ lineItemId: string; quantity: number }>; reason?: string; shippingAmount?: number }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }> };
5724
+ const refunded = await ordersService.refundLines(held.orderId, { lines: priced.map(({ lineItemId, quantity }) => ({ lineItemId, quantity })), reason: `Return ${held.id}`, ...(shippingAmount > 0 ? { shippingAmount } : {}) }, actor);
5671
5725
  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)));
5726
+ await this.db.update(channelReturns).set({ status: "approved", shippingAmount, updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id)));
5673
5727
  }
5674
5728
 
5675
5729
  const variantIds = priced.flatMap((line) => (line.variantId === null ? [] : [line.variantId]));
@@ -5681,13 +5735,13 @@ export class ChannelConnectorService {
5681
5735
  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
5736
  storeLines.push({ externalVariantId: externalId, quantity: line.quantity, amount: line.amount });
5683
5737
  }
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 });
5738
+ const amount = storeLines.reduce((sum, line) => sum + line.amount, 0) + shippingAmount;
5739
+ const booked = await connector.recordRefund(store as ChannelStore, exported.remoteOrderId, { lines: storeLines, amount, ...(shippingAmount > 0 ? { shippingAmount } : {}), reason: `Return: ${held.reason}`, restock: true });
5686
5740
  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
5741
  const lines = priced.map(({ lineItemId, quantity }) => ({ lineItemId, quantity }));
5688
5742
  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() } });
5743
+ .values({ organizationId: orgId, storeId: store.id, orderId: held.orderId, remoteRefundId: booked.value.remoteRefundId, amount, shippingAmount, lines, state: "executed", approvedBy: requireUserId(actor) })
5744
+ .onConflictDoUpdate({ target: [channelRefundRequests.storeId, channelRefundRequests.remoteRefundId], set: { amount, shippingAmount, adjustmentAmount: 0, lines, state: "executed", updatedAt: new Date() } });
5691
5745
  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
5746
  return closed ? Ok(closed) : PluginErr("Return not found.", "NOT_FOUND");
5693
5747
  }
@@ -5789,13 +5843,15 @@ export class ChannelConnectorService {
5789
5843
  remoteStatus.error.retriable === true ? "transient" : "definitive",
5790
5844
  );
5791
5845
  }
5792
- if (remoteStatus.value.status === "confirmed") {
5846
+ // `fulfilled` is a received order too: WooCommerce completes virtual and downloadable orders on
5847
+ // arrival, and an export waiting for `confirmed` would wait forever.
5848
+ if (remoteStatus.value.status === "confirmed" || remoteStatus.value.status === "fulfilled") {
5793
5849
  return this.transitionExport(
5794
5850
  orgId,
5795
5851
  created.value.id,
5796
5852
  "confirmed",
5797
5853
  requireUserId(actor),
5798
- "Remote order confirmed.",
5854
+ remoteStatus.value.status === "fulfilled" ? "Remote order received and completed by the store." : "Remote order confirmed.",
5799
5855
  );
5800
5856
  }
5801
5857
  if (remoteStatus.value.status === "failed" || remoteStatus.value.status === "cancelled") {
@@ -5828,7 +5884,7 @@ export class ChannelConnectorService {
5828
5884
  for (const line of selected) {
5829
5885
  const mapping = (line.variantId && mappings.find((item) => item.kind === "variant" && item.variantId === line.variantId)) ?? mappings.find((item) => item.kind === "entity" && item.entityId === line.entityId);
5830
5886
  if (!mapping) return PluginErr(`External mapping is missing for order line ${line.id}.`, "MAPPING_MISSING");
5831
- lines.push({ externalVariantId: mapping.externalId, ...(line.sku ? { sku: line.sku } : {}), title: line.title, quantity: line.quantity, unitPrice: line.unitPrice, totalPrice: line.totalPrice });
5887
+ lines.push({ externalVariantId: mapping.externalId, ...(line.sku ? { sku: line.sku } : {}), title: line.title, quantity: line.quantity, unitPrice: line.unitPrice, totalPrice: line.totalPrice, ...(line.discountAmount > 0 ? { discountAmount: line.discountAmount } : {}) });
5832
5888
  }
5833
5889
 
5834
5890
  let email: string | null = null;
@@ -5876,7 +5932,9 @@ export class ChannelConnectorService {
5876
5932
  // ponytail: like delivery, a discount is sent only with the whole order; apportion it when multi-store orders exist.
5877
5933
  const discountCode = typeof metadata.promotionCode === "string" && metadata.promotionCode.trim() !== "" ? metadata.promotionCode.trim() : "DISCOUNT";
5878
5934
  const discount = selected.length === lineItems.length && order.discountTotal > 0 ? { code: discountCode, amount: order.discountTotal } : null;
5879
- return Ok({ orderId, currency: order.currency, grandTotal: linesTotal + (shipping?.amount ?? 0) - (discount?.amount ?? 0), lines, ...(shipping ? { shipping } : {}), ...(discount ? { discount } : {}), customer: { name, email, shippingAddress } });
5935
+ // A line's discount travels with the order discount it is a share of, never without it.
5936
+ const slicedLines = discount ? lines : lines.map(({ discountAmount: _share, ...line }) => line);
5937
+ return Ok({ orderId, currency: order.currency, grandTotal: linesTotal + (shipping?.amount ?? 0) - (discount?.amount ?? 0), lines: slicedLines, ...(shipping ? { shipping } : {}), ...(discount ? { discount } : {}), customer: { name, email, shippingAddress } });
5880
5938
  }
5881
5939
 
5882
5940
  async reapExports(input: { definitiveMs: number; transientMs: number }): Promise<{ abandonedCount: number; refundedOrderIds: string[] }> {