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