@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 +1 -1
- package/dist/index.js +16 -0
- package/dist/mock-connector.d.ts +1 -0
- package/dist/mock-connector.js +1 -1
- package/dist/schema.d.ts +63 -0
- package/dist/schema.js +7 -0
- package/dist/service.d.ts +21 -2
- package/dist/service.js +163 -19
- package/package.json +2 -2
- package/src/index.ts +21 -0
- package/src/mock-connector.ts +1 -1
- package/src/schema.ts +15 -0
- package/src/service.ts +162 -19
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")
|
package/dist/mock-connector.d.ts
CHANGED
package/dist/mock-connector.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4201
|
-
|
|
4202
|
-
const
|
|
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
|
|
4219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4440
|
-
if (
|
|
4441
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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")
|
package/src/mock-connector.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
|
5342
|
-
|
|
5343
|
-
const
|
|
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
|
|
5361
|
-
|
|
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
|
-
|
|
5369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5604
|
-
if (
|
|
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
|
|
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
|
-
|
|
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[] }> {
|