@porulle/plugin-channel-connector 0.75.0 → 0.77.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, ChannelReturnView, 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,22 @@ 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
+ .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
+ });
747
+ channels.post("/returns/{id}/decline")
748
+ .summary("Decline a held return")
749
+ .permission("channels:connect")
750
+ .handler(async ({ params, orgId, actor, raw }) => unwrap(await service.declineReturn(orgId, params.id, { orgId, actor, raw })));
735
751
  channels.post("/exports/{id}/retry")
736
752
  .summary("Retry a failed channel order export")
737
753
  .permission("channels:manage")
@@ -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";
@@ -2354,4 +2405,16 @@ export type ChannelCatalogPushEvent = typeof channelCatalogPushEvents.$inferSele
2354
2405
  export type ChannelOrderExport = typeof channelOrderExports.$inferSelect;
2355
2406
  export type ChannelExportEvent = typeof channelExportEvents.$inferSelect;
2356
2407
  export type ChannelRefundRequest = typeof channelRefundRequests.$inferSelect;
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
+ };
2357
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 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"];
@@ -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> {
@@ -710,7 +712,10 @@ export declare class ChannelConnectorService {
710
712
  private setInventoryLevel;
711
713
  private createRefundRequest;
712
714
  private executeRefund;
713
- 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
+ }>>>;
714
719
  approveRefund(orgId: string, id: string, actor: {
715
720
  userId: string;
716
721
  }): Promise<PluginResult<ChannelRefundRequest>>;
@@ -752,6 +757,20 @@ export declare class ChannelConnectorService {
752
757
  remoteReturnId: string;
753
758
  status: string;
754
759
  }>>;
760
+ /** Returns held on the platform (stores with none of their own) that wait for their merchant. */
761
+ listReturns(orgId: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturnView[]>>;
762
+ /**
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.
768
+ */
769
+ approveReturn(orgId: string, id: string, context?: StoreReadContext, options?: {
770
+ refundShipping?: boolean;
771
+ }): Promise<PluginResult<ChannelReturn>>;
772
+ /** The merchant refuses a held return. Nothing moves. */
773
+ declineReturn(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturn>>;
755
774
  /** A cancelled or refunded order: nothing to push to a store, ever again. */
756
775
  isOrderClosed(orgId: string, orderId: string): Promise<boolean>;
757
776
  exportOrder(orgId: string, storeId: string, slice: ChannelOrderSlice, actor: Actor): Promise<PluginResult<ChannelOrderExport>>;
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";
@@ -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;
@@ -4174,14 +4176,15 @@ export class ChannelConnectorService {
4174
4176
  const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
4175
4177
  const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id)));
4176
4178
  const refundLines = [];
4177
- 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;
4178
4181
  for (const line of event.lines) {
4179
4182
  const externalId = line.externalVariantId;
4180
4183
  const quantity = line.quantity;
4181
4184
  const mapping = mappings.find((item) => item.externalId === externalId);
4182
4185
  const orderLine = mapping ? orderLines.find((item) => item.variantId === mapping.variantId || item.entityId === mapping.entityId) : undefined;
4183
4186
  if (!orderLine || !Number.isInteger(quantity) || quantity < 1 || quantity > orderLine.quantity - orderLine.refundedQuantity)
4184
- clean = false;
4187
+ mapped = false;
4185
4188
  else
4186
4189
  refundLines.push({ lineItemId: orderLine.id, quantity });
4187
4190
  }
@@ -4189,17 +4192,33 @@ export class ChannelConnectorService {
4189
4192
  const item = orderLines.find((candidate) => candidate.id === line.lineItemId);
4190
4193
  return sum + Math.round((item.totalPrice + item.taxAmount - item.discountAmount) * line.quantity / item.quantity);
4191
4194
  }, 0);
4192
- // What the store refunded, when it says, and never more than the platform's own price for the lines:
4193
- // a store can refund part of a line, or a line it discounted, but cannot claim more than it sold.
4194
- const amount = event.amount === undefined ? priced : Math.min(Math.max(0, event.amount), priced);
4195
4195
  const [order] = await this.db.select().from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
4196
4196
  if (!order)
4197
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);
4198
4216
  const max = this.options.refundAutoMax ?? order.amountCaptured ?? order.grandTotal;
4199
4217
  const ageOk = Date.now() - store.createdAt.getTime() >= (this.options.newStoreDays ?? 7) * 86_400_000;
4200
- // Only a whole-line refund is automatic; any other amount is a person's call.
4201
- const auto = clean && amount > 0 && amount === priced && ageOk && amount <= max;
4202
- 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();
4203
4222
  const request = rows[0];
4204
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) });
4205
4224
  if (auto) {
@@ -4215,16 +4234,28 @@ export class ChannelConnectorService {
4215
4234
  }
4216
4235
  async executeRefund(request, lines, actor) {
4217
4236
  const ordersService = this.services.orders;
4218
- // A request that kept its lines pays back its own amount; an older one is priced from the lines rebuilt for it.
4219
- 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);
4220
4247
  if (!result.ok)
4221
4248
  return PluginErr(result.error?.message ?? "Refund execution failed.");
4222
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();
4223
4250
  await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: requireUserId(actor) });
4224
4251
  return Ok(updated);
4225
4252
  }
4253
+ /** Held refunds, each with the order number an approver knows the order by. */
4226
4254
  async listRefundRequests(orgId) {
4227
- 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 })));
4228
4259
  }
4229
4260
  async approveRefund(orgId, id, actor) {
4230
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();
@@ -4416,7 +4447,9 @@ export class ChannelConnectorService {
4416
4447
  if (!store || store.status !== "connected")
4417
4448
  return PluginErr("The store this order went to is not connected.", "NOT_FOUND");
4418
4449
  const connector = this.connectors.get(store.provider);
4419
- if (!connector?.requestReturn)
4450
+ // A store with no returns of its own (WooCommerce) has them held here, for its merchant to approve.
4451
+ const hosted = connector?.requestReturn === undefined && connector?.recordRefund !== undefined;
4452
+ if (!connector || (!connector.requestReturn && !hosted))
4420
4453
  return PluginErr(`Returns are not available for ${store.provider} stores.`, "NOT_IMPLEMENTED");
4421
4454
  const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
4422
4455
  const variantIds = lines.flatMap((line) => (line.variantId === null ? [] : [line.variantId]));
@@ -4436,14 +4469,18 @@ export class ChannelConnectorService {
4436
4469
  return PluginErr(`The store has no record of line ${wanted.orderLineItemId}, so it cannot take it back.`, "CHANNEL_MAPPING_MISSING");
4437
4470
  remote.push({ externalVariantId: externalId, quantity: wanted.quantity });
4438
4471
  }
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);
4472
+ let remoteReturnId = `${PLATFORM_RETURN_PREFIX}${crypto.randomUUID()}`;
4473
+ if (connector.requestReturn) {
4474
+ const asked = await connector.requestReturn(store, exported.remoteOrderId, { lines: remote, reason: input.reason, ...(input.note ? { note: input.note } : {}) });
4475
+ if (!asked.ok)
4476
+ return PluginErr(asked.error.message, asked.error.code);
4477
+ remoteReturnId = asked.value.remoteReturnId;
4478
+ }
4442
4479
  const [row] = await this.db.insert(channelReturns).values({
4443
4480
  organizationId: orgId,
4444
4481
  storeId: store.id,
4445
4482
  orderId,
4446
- remoteReturnId: asked.value.remoteReturnId,
4483
+ remoteReturnId,
4447
4484
  status: "requested",
4448
4485
  lines: input.lines,
4449
4486
  reason: input.reason,
@@ -4453,6 +4490,111 @@ export class ChannelConnectorService {
4453
4490
  return PluginErr("The return could not be recorded.");
4454
4491
  return Ok(row);
4455
4492
  }
4493
+ /** Returns held on the platform (stores with none of their own) that wait for their merchant. */
4494
+ async listReturns(orgId, context) {
4495
+ const allowed = await this.allowedStores(orgId, context);
4496
+ if (allowed !== null && allowed.length === 0)
4497
+ return Ok([]);
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));
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
+ }));
4517
+ }
4518
+ /**
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.
4524
+ */
4525
+ async approveReturn(orgId, id, context, options = {}) {
4526
+ const [held] = await this.db.select().from(channelReturns).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, id)));
4527
+ if (!held || !held.remoteReturnId.startsWith(PLATFORM_RETURN_PREFIX))
4528
+ return PluginErr("Return not found.", "NOT_FOUND");
4529
+ const reached = await this.reachableStore(orgId, held.storeId, context);
4530
+ if (!reached.ok)
4531
+ return PluginErr("Return not found.", "NOT_FOUND");
4532
+ if (held.status !== "requested" && held.status !== "approved")
4533
+ return PluginErr(`This return is already ${held.status}.`, "CONFLICT");
4534
+ const store = reached.value;
4535
+ const connector = this.connectors.get(store.provider);
4536
+ if (!connector?.recordRefund)
4537
+ return PluginErr(`Returns are not available for ${store.provider} stores.`, "NOT_IMPLEMENTED");
4538
+ const [exported] = await this.db.select({ remoteOrderId: channelOrderExports.remoteOrderId }).from(channelOrderExports)
4539
+ .where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.orderId, held.orderId), eq(channelOrderExports.storeId, store.id)));
4540
+ if (!exported?.remoteOrderId)
4541
+ return PluginErr("This order never reached the store.", "NOT_FOUND");
4542
+ const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, held.orderId));
4543
+ const priced = [];
4544
+ for (const line of held.lines) {
4545
+ const item = orderLines.find((candidate) => candidate.id === line.orderLineItemId);
4546
+ if (!item)
4547
+ return PluginErr(`Line ${line.orderLineItemId} is no longer on this order.`, "VALIDATION_FAILED");
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) });
4549
+ }
4550
+ const actor = createSystemActor(orgId);
4551
+ let shippingAmount = held.shippingAmount;
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
+ }
4559
+ const ordersService = this.services.orders;
4560
+ const refunded = await ordersService.refundLines(held.orderId, { lines: priced.map(({ lineItemId, quantity }) => ({ lineItemId, quantity })), reason: `Return ${held.id}`, ...(shippingAmount > 0 ? { shippingAmount } : {}) }, actor);
4561
+ if (!refunded.ok)
4562
+ return PluginErr(refunded.error?.message ?? "The shopper could not be paid back.", "REFUND_FAILED");
4563
+ await this.db.update(channelReturns).set({ status: "approved", shippingAmount, updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id)));
4564
+ }
4565
+ const variantIds = priced.flatMap((line) => (line.variantId === null ? [] : [line.variantId]));
4566
+ const mapped = variantIds.length === 0 ? [] : await this.db.select({ variantId: channelEntityMap.variantId, externalId: channelEntityMap.externalId }).from(channelEntityMap)
4567
+ .where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.variantId, variantIds)));
4568
+ const storeLines = [];
4569
+ for (const line of priced) {
4570
+ const externalId = mapped.find((entry) => entry.variantId === line.variantId)?.externalId;
4571
+ if (!externalId)
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");
4573
+ storeLines.push({ externalVariantId: externalId, quantity: line.quantity, amount: line.amount });
4574
+ }
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 });
4577
+ if (!booked.ok)
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");
4579
+ const lines = priced.map(({ lineItemId, quantity }) => ({ lineItemId, quantity }));
4580
+ await this.db.insert(channelRefundRequests)
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() } });
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();
4584
+ return closed ? Ok(closed) : PluginErr("Return not found.", "NOT_FOUND");
4585
+ }
4586
+ /** The merchant refuses a held return. Nothing moves. */
4587
+ async declineReturn(orgId, id, context) {
4588
+ const [held] = await this.db.select().from(channelReturns).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, id)));
4589
+ if (!held || !held.remoteReturnId.startsWith(PLATFORM_RETURN_PREFIX))
4590
+ return PluginErr("Return not found.", "NOT_FOUND");
4591
+ const reached = await this.reachableStore(orgId, held.storeId, context);
4592
+ if (!reached.ok)
4593
+ return PluginErr("Return not found.", "NOT_FOUND");
4594
+ const [declined] = await this.db.update(channelReturns).set({ status: "declined", updatedAt: new Date() })
4595
+ .where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id), eq(channelReturns.status, "requested"))).returning();
4596
+ return declined ? Ok(declined) : PluginErr(`This return is already ${held.status}.`, "CONFLICT");
4597
+ }
4456
4598
  /** A cancelled or refunded order: nothing to push to a store, ever again. */
4457
4599
  async isOrderClosed(orgId, orderId) {
4458
4600
  const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
@@ -4526,7 +4668,7 @@ export class ChannelConnectorService {
4526
4668
  const mapping = (line.variantId && mappings.find((item) => item.kind === "variant" && item.variantId === line.variantId)) ?? mappings.find((item) => item.kind === "entity" && item.entityId === line.entityId);
4527
4669
  if (!mapping)
4528
4670
  return PluginErr(`External mapping is missing for order line ${line.id}.`, "MAPPING_MISSING");
4529
- lines.push({ externalVariantId: mapping.externalId, ...(line.sku ? { sku: line.sku } : {}), title: line.title, quantity: line.quantity, unitPrice: line.unitPrice, totalPrice: line.totalPrice });
4671
+ 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 } : {}) });
4530
4672
  }
4531
4673
  let email = null;
4532
4674
  let name = "";
@@ -4575,7 +4717,9 @@ export class ChannelConnectorService {
4575
4717
  // ponytail: like delivery, a discount is sent only with the whole order; apportion it when multi-store orders exist.
4576
4718
  const discountCode = typeof metadata.promotionCode === "string" && metadata.promotionCode.trim() !== "" ? metadata.promotionCode.trim() : "DISCOUNT";
4577
4719
  const discount = selected.length === lineItems.length && order.discountTotal > 0 ? { code: discountCode, amount: order.discountTotal } : null;
4578
- return Ok({ orderId, currency: order.currency, grandTotal: linesTotal + (shipping?.amount ?? 0) - (discount?.amount ?? 0), lines, ...(shipping ? { shipping } : {}), ...(discount ? { discount } : {}), customer: { name, email, shippingAddress } });
4720
+ // A line's discount travels with the order discount it is a share of, never without it.
4721
+ const slicedLines = discount ? lines : lines.map(({ discountAmount: _share, ...line }) => line);
4722
+ return Ok({ orderId, currency: order.currency, grandTotal: linesTotal + (shipping?.amount ?? 0) - (discount?.amount ?? 0), lines: slicedLines, ...(shipping ? { shipping } : {}), ...(discount ? { discount } : {}), customer: { name, email, shippingAddress } });
4579
4723
  }
4580
4724
  async reapExports(input) {
4581
4725
  const now = Date.now();
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.77.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.77.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^24.5.2",
package/src/index.ts CHANGED
@@ -259,6 +259,8 @@ export type {
259
259
  ChannelOrderExport,
260
260
  ChannelRefundEvent,
261
261
  ChannelRefundRequest,
262
+ ChannelReturn,
263
+ ChannelReturnView,
262
264
  ConnectedStore,
263
265
  StoreHealth,
264
266
  } from "./schema.js";
@@ -909,6 +911,25 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
909
911
  .permission("channels:manage")
910
912
  .handler(async ({ params, orgId, actor }: ChannelRouteContext) => unwrap(await service.rejectRefund(orgId, params.id!, { userId: requireUserId(actor) })));
911
913
 
914
+ channels.get("/returns")
915
+ .summary("List the returns held on the platform that wait for their merchant")
916
+ .permission("channels:connect")
917
+ .handler(async ({ orgId, actor, raw }: ChannelRouteContext) => unwrap(await service.listReturns(orgId, { orgId, actor, raw })));
918
+
919
+ channels.post("/returns/{id}/approve")
920
+ .summary("Approve a held return: pay the shopper back and book the refund at the store")
921
+ .permission("channels:connect")
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
+ });
927
+
928
+ channels.post("/returns/{id}/decline")
929
+ .summary("Decline a held return")
930
+ .permission("channels:connect")
931
+ .handler(async ({ params, orgId, actor, raw }: ChannelRouteContext) => unwrap(await service.declineReturn(orgId, params.id!, { orgId, actor, raw })));
932
+
912
933
  channels.post("/exports/{id}/retry")
913
934
  .summary("Retry a failed channel order export")
914
935
  .permission("channels:manage")
@@ -94,7 +94,7 @@ const channelEventSchema = z.discriminatedUnion("kind", [
94
94
  z.object({ kind: z.literal("inventory.changed"), levels: z.array(level) }),
95
95
  z.object({ kind: z.literal("order.cancelled"), remoteOrderId: z.string() }),
96
96
  z.object({ kind: z.literal("order.fulfilled"), remoteOrderId: z.string(), partial: z.boolean(), shipments: z.array(shipment) }),
97
- z.object({ kind: z.literal("refund.created"), remoteOrderId: z.string(), remoteRefundId: z.string(), lines: z.array(z.object({ externalVariantId: z.string(), quantity: z.number().int() })), 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"),
@@ -328,4 +335,12 @@ export type ChannelCatalogPushEvent = typeof channelCatalogPushEvents.$inferSele
328
335
  export type ChannelOrderExport = typeof channelOrderExports.$inferSelect;
329
336
  export type ChannelExportEvent = typeof channelExportEvents.$inferSelect;
330
337
  export type ChannelRefundRequest = typeof channelRefundRequests.$inferSelect;
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
+ };
331
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,
@@ -94,6 +95,8 @@ import {
94
95
  type ChannelCatalogConflict,
95
96
  type ChannelOrderExport,
96
97
  type ChannelRefundRequest,
98
+ type ChannelReturn,
99
+ type ChannelReturnView,
97
100
  type ConnectedStore,
98
101
  } from "./schema.js";
99
102
  import type { StoreHealth } from "./schema.js";
@@ -142,6 +145,8 @@ export const REMOTE_ORDER_REFRESH_MS = 5 * 60 * 1000;
142
145
 
143
146
  /** How often a merchant's visit may make the plugin call a store to check its health. */
144
147
  export const STORE_HEALTH_INTERVAL_MS = 10 * 60 * 1000;
148
+ /** The `remote_return_id` of a return held on the platform, for a store with no returns of its own. */
149
+ export const PLATFORM_RETURN_PREFIX = "platform:";
145
150
 
146
151
  export const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
147
152
 
@@ -5318,29 +5323,45 @@ export class ChannelConnectorService {
5318
5323
  const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
5319
5324
  const mappings = await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id)));
5320
5325
  const refundLines: Array<{ lineItemId: string; quantity: number }> = [];
5321
- 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;
5322
5328
  for (const line of event.lines) {
5323
5329
  const externalId = line.externalVariantId;
5324
5330
  const quantity = line.quantity;
5325
5331
  const mapping = mappings.find((item) => item.externalId === externalId);
5326
5332
  const orderLine = mapping ? orderLines.find((item) => item.variantId === mapping.variantId || item.entityId === mapping.entityId) : undefined;
5327
- 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;
5328
5334
  else refundLines.push({ lineItemId: orderLine.id, quantity });
5329
5335
  }
5330
5336
  const priced = refundLines.reduce((sum, line) => {
5331
5337
  const item = orderLines.find((candidate) => candidate.id === line.lineItemId)!;
5332
5338
  return sum + Math.round((item.totalPrice + item.taxAmount - item.discountAmount) * line.quantity / item.quantity);
5333
5339
  }, 0);
5334
- // What the store refunded, when it says, and never more than the platform's own price for the lines:
5335
- // a store can refund part of a line, or a line it discounted, but cannot claim more than it sold.
5336
- const amount = event.amount === undefined ? priced : Math.min(Math.max(0, event.amount), priced);
5337
5340
  const [order] = await this.db.select().from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
5338
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);
5339
5359
  const max = this.options.refundAutoMax ?? order.amountCaptured ?? order.grandTotal;
5340
5360
  const ageOk = Date.now() - store.createdAt.getTime() >= (this.options.newStoreDays ?? 7) * 86_400_000;
5341
- // Only a whole-line refund is automatic; any other amount is a person's call.
5342
- const auto = clean && amount > 0 && amount === priced && ageOk && amount <= max;
5343
- 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();
5344
5365
  const request = rows[0] as ChannelRefundRequest;
5345
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) });
5346
5367
  if (auto) {
@@ -5356,17 +5377,29 @@ export class ChannelConnectorService {
5356
5377
  }
5357
5378
 
5358
5379
  private async executeRefund(request: ChannelRefundRequest, lines: Array<{ lineItemId: string; quantity: number }>, actor: Actor): Promise<PluginResult<ChannelRefundRequest>> {
5359
- 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 } }> };
5360
- // A request that kept its lines pays back its own amount; an older one is priced from the lines rebuilt for it.
5361
- 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);
5362
5391
  if (!result.ok) return PluginErr(result.error?.message ?? "Refund execution failed.");
5363
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();
5364
5393
  await this.db.insert(channelRefundEvents).values({ organizationId: request.organizationId, requestId: request.id, fromState: "approved", toState: "executed", reason: "Platform refund executed", changedBy: requireUserId(actor) });
5365
5394
  return Ok(updated as ChannelRefundRequest);
5366
5395
  }
5367
5396
 
5368
- async listRefundRequests(orgId: string): Promise<PluginResult<ChannelRefundRequest[]>> {
5369
- 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 })));
5370
5403
  }
5371
5404
 
5372
5405
  async approveRefund(orgId: string, id: string, actor: { userId: string }): Promise<PluginResult<ChannelRefundRequest>> {
@@ -5582,7 +5615,9 @@ export class ChannelConnectorService {
5582
5615
  const store = await this.getStoreRecord(orgId, exported.storeId);
5583
5616
  if (!store || store.status !== "connected") return PluginErr("The store this order went to is not connected.", "NOT_FOUND");
5584
5617
  const connector = this.connectors.get(store.provider);
5585
- if (!connector?.requestReturn) return PluginErr(`Returns are not available for ${store.provider} stores.`, "NOT_IMPLEMENTED");
5618
+ // A store with no returns of its own (WooCommerce) has them held here, for its merchant to approve.
5619
+ const hosted = connector?.requestReturn === undefined && connector?.recordRefund !== undefined;
5620
+ if (!connector || (!connector.requestReturn && !hosted)) return PluginErr(`Returns are not available for ${store.provider} stores.`, "NOT_IMPLEMENTED");
5586
5621
 
5587
5622
  const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
5588
5623
  const variantIds = lines.flatMap((line) => (line.variantId === null ? [] : [line.variantId]));
@@ -5600,13 +5635,17 @@ export class ChannelConnectorService {
5600
5635
  remote.push({ externalVariantId: externalId, quantity: wanted.quantity });
5601
5636
  }
5602
5637
 
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);
5638
+ let remoteReturnId = `${PLATFORM_RETURN_PREFIX}${crypto.randomUUID()}`;
5639
+ if (connector.requestReturn) {
5640
+ const asked = await connector.requestReturn(store as ChannelStore, exported.remoteOrderId, { lines: remote, reason: input.reason, ...(input.note ? { note: input.note } : {}) });
5641
+ if (!asked.ok) return PluginErr(asked.error.message, asked.error.code);
5642
+ remoteReturnId = asked.value.remoteReturnId;
5643
+ }
5605
5644
  const [row] = await this.db.insert(channelReturns).values({
5606
5645
  organizationId: orgId,
5607
5646
  storeId: store.id,
5608
5647
  orderId,
5609
- remoteReturnId: asked.value.remoteReturnId,
5648
+ remoteReturnId,
5610
5649
  status: "requested",
5611
5650
  lines: input.lines,
5612
5651
  reason: input.reason,
@@ -5616,6 +5655,108 @@ export class ChannelConnectorService {
5616
5655
  return Ok(row);
5617
5656
  }
5618
5657
 
5658
+ /** Returns held on the platform (stores with none of their own) that wait for their merchant. */
5659
+ async listReturns(orgId: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturnView[]>> {
5660
+ const allowed = await this.allowedStores(orgId, context);
5661
+ if (allowed !== null && allowed.length === 0) return Ok([]);
5662
+ const rows = await this.db.select().from(channelReturns).where(and(
5663
+ eq(channelReturns.organizationId, orgId),
5664
+ eq(channelReturns.status, "requested"),
5665
+ sql`${channelReturns.remoteReturnId} like ${`${PLATFORM_RETURN_PREFIX}%`}`,
5666
+ ...(allowed === null ? [] : [inArray(channelReturns.storeId, [...allowed])]),
5667
+ )).orderBy(desc(channelReturns.createdAt));
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
+ }));
5685
+ }
5686
+
5687
+ /**
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.
5693
+ */
5694
+ async approveReturn(orgId: string, id: string, context?: StoreReadContext, options: { refundShipping?: boolean } = {}): Promise<PluginResult<ChannelReturn>> {
5695
+ const [held] = await this.db.select().from(channelReturns).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, id)));
5696
+ if (!held || !held.remoteReturnId.startsWith(PLATFORM_RETURN_PREFIX)) return PluginErr("Return not found.", "NOT_FOUND");
5697
+ const reached = await this.reachableStore(orgId, held.storeId, context);
5698
+ if (!reached.ok) return PluginErr("Return not found.", "NOT_FOUND");
5699
+ if (held.status !== "requested" && held.status !== "approved") return PluginErr(`This return is already ${held.status}.`, "CONFLICT");
5700
+ const store = reached.value;
5701
+ const connector = this.connectors.get(store.provider);
5702
+ if (!connector?.recordRefund) return PluginErr(`Returns are not available for ${store.provider} stores.`, "NOT_IMPLEMENTED");
5703
+ const [exported] = await this.db.select({ remoteOrderId: channelOrderExports.remoteOrderId }).from(channelOrderExports)
5704
+ .where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.orderId, held.orderId), eq(channelOrderExports.storeId, store.id)));
5705
+ if (!exported?.remoteOrderId) return PluginErr("This order never reached the store.", "NOT_FOUND");
5706
+
5707
+ const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, held.orderId));
5708
+ const priced: Array<{ lineItemId: string; quantity: number; variantId: string | null; amount: number }> = [];
5709
+ for (const line of held.lines) {
5710
+ const item = orderLines.find((candidate) => candidate.id === line.orderLineItemId);
5711
+ if (!item) return PluginErr(`Line ${line.orderLineItemId} is no longer on this order.`, "VALIDATION_FAILED");
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) });
5713
+ }
5714
+ const actor = createSystemActor(orgId);
5715
+ let shippingAmount = held.shippingAmount;
5716
+ if (held.status === "requested") {
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);
5725
+ if (!refunded.ok) return PluginErr(refunded.error?.message ?? "The shopper could not be paid back.", "REFUND_FAILED");
5726
+ await this.db.update(channelReturns).set({ status: "approved", shippingAmount, updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id)));
5727
+ }
5728
+
5729
+ const variantIds = priced.flatMap((line) => (line.variantId === null ? [] : [line.variantId]));
5730
+ const mapped = variantIds.length === 0 ? [] : await this.db.select({ variantId: channelEntityMap.variantId, externalId: channelEntityMap.externalId }).from(channelEntityMap)
5731
+ .where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.variantId, variantIds)));
5732
+ const storeLines: Array<{ externalVariantId: string; quantity: number; amount: number }> = [];
5733
+ for (const line of priced) {
5734
+ const externalId = mapped.find((entry) => entry.variantId === line.variantId)?.externalId;
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");
5736
+ storeLines.push({ externalVariantId: externalId, quantity: line.quantity, amount: line.amount });
5737
+ }
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 });
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");
5741
+ const lines = priced.map(({ lineItemId, quantity }) => ({ lineItemId, quantity }));
5742
+ await this.db.insert(channelRefundRequests)
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() } });
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();
5746
+ return closed ? Ok(closed) : PluginErr("Return not found.", "NOT_FOUND");
5747
+ }
5748
+
5749
+ /** The merchant refuses a held return. Nothing moves. */
5750
+ async declineReturn(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturn>> {
5751
+ const [held] = await this.db.select().from(channelReturns).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, id)));
5752
+ if (!held || !held.remoteReturnId.startsWith(PLATFORM_RETURN_PREFIX)) return PluginErr("Return not found.", "NOT_FOUND");
5753
+ const reached = await this.reachableStore(orgId, held.storeId, context);
5754
+ if (!reached.ok) return PluginErr("Return not found.", "NOT_FOUND");
5755
+ const [declined] = await this.db.update(channelReturns).set({ status: "declined", updatedAt: new Date() })
5756
+ .where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id), eq(channelReturns.status, "requested"))).returning();
5757
+ return declined ? Ok(declined) : PluginErr(`This return is already ${held.status}.`, "CONFLICT");
5758
+ }
5759
+
5619
5760
  /** A cancelled or refunded order: nothing to push to a store, ever again. */
5620
5761
  async isOrderClosed(orgId: string, orderId: string): Promise<boolean> {
5621
5762
  const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
@@ -5741,7 +5882,7 @@ export class ChannelConnectorService {
5741
5882
  for (const line of selected) {
5742
5883
  const mapping = (line.variantId && mappings.find((item) => item.kind === "variant" && item.variantId === line.variantId)) ?? mappings.find((item) => item.kind === "entity" && item.entityId === line.entityId);
5743
5884
  if (!mapping) return PluginErr(`External mapping is missing for order line ${line.id}.`, "MAPPING_MISSING");
5744
- lines.push({ externalVariantId: mapping.externalId, ...(line.sku ? { sku: line.sku } : {}), title: line.title, quantity: line.quantity, unitPrice: line.unitPrice, totalPrice: line.totalPrice });
5885
+ 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 } : {}) });
5745
5886
  }
5746
5887
 
5747
5888
  let email: string | null = null;
@@ -5789,7 +5930,9 @@ export class ChannelConnectorService {
5789
5930
  // ponytail: like delivery, a discount is sent only with the whole order; apportion it when multi-store orders exist.
5790
5931
  const discountCode = typeof metadata.promotionCode === "string" && metadata.promotionCode.trim() !== "" ? metadata.promotionCode.trim() : "DISCOUNT";
5791
5932
  const discount = selected.length === lineItems.length && order.discountTotal > 0 ? { code: discountCode, amount: order.discountTotal } : null;
5792
- return Ok({ orderId, currency: order.currency, grandTotal: linesTotal + (shipping?.amount ?? 0) - (discount?.amount ?? 0), lines, ...(shipping ? { shipping } : {}), ...(discount ? { discount } : {}), customer: { name, email, shippingAddress } });
5933
+ // A line's discount travels with the order discount it is a share of, never without it.
5934
+ const slicedLines = discount ? lines : lines.map(({ discountAmount: _share, ...line }) => line);
5935
+ return Ok({ orderId, currency: order.currency, grandTotal: linesTotal + (shipping?.amount ?? 0) - (discount?.amount ?? 0), lines: slicedLines, ...(shipping ? { shipping } : {}), ...(discount ? { discount } : {}), customer: { name, email, shippingAddress } });
5793
5936
  }
5794
5937
 
5795
5938
  async reapExports(input: { definitiveMs: number; transientMs: number }): Promise<{ abandonedCount: number; refundedOrderIds: string[] }> {