@porulle/plugin-channel-connector 0.75.0 → 0.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +12 -0
- package/dist/schema.d.ts +1 -0
- package/dist/service.d.ts +14 -1
- package/dist/service.js +93 -5
- package/package.json +2 -2
- package/src/index.ts +16 -0
- package/src/schema.ts +1 -0
- package/src/service.ts +91 -4
package/dist/index.d.ts
CHANGED
|
@@ -27,5 +27,5 @@ export declare const CHANNEL_MAX_BATCHES_PER_SWEEP = 5000;
|
|
|
27
27
|
export { signState, verifyState } from "./oauth-state.js";
|
|
28
28
|
export type { BackfillCatalogOptions, BackfillCatalogReport, BuildCatalogPushItemsOptions, BuildCatalogPushItemsResult, CatalogPushAssemblyField, CatalogPushAssemblyImage, CatalogPushAssemblyItem, CatalogPushPreviewBefore, CatalogPushPreviewBeforeStatus, CatalogPushPreviewDiff, CatalogPushPreviewItem, CatalogPushPreviewResult, CatalogPushPreviewUnavailable, PushCatalogToStoreResult, CatalogPushJobResult, CatalogConvergenceFailure, CatalogFieldConflict, CatalogFieldSkip, CatalogPushFieldSkip, CatalogPushSkipReason, CatalogConflictState, CatalogWriteSettings, AfterStoreConnected, BindConnectedStore, ChannelComplianceData, ChannelConnectorPluginOptions, ConfineStores, ConnectClaims, OnStoreCatalogChanged, StoreConnectActor, StoreReadContext, ChannelStockLine, ExportState, PublicConnectedStore, ReconcileReport, } from "./service.js";
|
|
29
29
|
export type { OAuthStatePayload, OAuthStateResult } from "./oauth-state.js";
|
|
30
|
-
export type { ChannelCatalogPush, ChannelCatalogPushEvent, ChannelCatalogConflict, ChannelCatalogConflictEvent, ChannelEntityMapEntry, ChannelExportEvent, ChannelOrderExport, ChannelRefundEvent, ChannelRefundRequest, ConnectedStore, StoreHealth, } from "./schema.js";
|
|
30
|
+
export type { ChannelCatalogPush, ChannelCatalogPushEvent, ChannelCatalogConflict, ChannelCatalogConflictEvent, ChannelEntityMapEntry, ChannelExportEvent, ChannelOrderExport, ChannelRefundEvent, ChannelRefundRequest, ChannelReturn, ConnectedStore, StoreHealth, } from "./schema.js";
|
|
31
31
|
export declare function channelConnectorPlugin(options?: ChannelConnectorPluginOptions): import("@porulle/core").CommercePlugin;
|
package/dist/index.js
CHANGED
|
@@ -732,6 +732,18 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
732
732
|
.summary("Reject a channel refund request")
|
|
733
733
|
.permission("channels:manage")
|
|
734
734
|
.handler(async ({ params, orgId, actor }) => unwrap(await service.rejectRefund(orgId, params.id, { userId: requireUserId(actor) })));
|
|
735
|
+
channels.get("/returns")
|
|
736
|
+
.summary("List the returns held on the platform that wait for their merchant")
|
|
737
|
+
.permission("channels:connect")
|
|
738
|
+
.handler(async ({ orgId, actor, raw }) => unwrap(await service.listReturns(orgId, { orgId, actor, raw })));
|
|
739
|
+
channels.post("/returns/{id}/approve")
|
|
740
|
+
.summary("Approve a held return: pay the shopper back and book the refund at the store")
|
|
741
|
+
.permission("channels:connect")
|
|
742
|
+
.handler(async ({ params, orgId, actor, raw }) => unwrap(await service.approveReturn(orgId, params.id, { orgId, actor, raw })));
|
|
743
|
+
channels.post("/returns/{id}/decline")
|
|
744
|
+
.summary("Decline a held return")
|
|
745
|
+
.permission("channels:connect")
|
|
746
|
+
.handler(async ({ params, orgId, actor, raw }) => unwrap(await service.declineReturn(orgId, params.id, { orgId, actor, raw })));
|
|
735
747
|
channels.post("/exports/{id}/retry")
|
|
736
748
|
.summary("Retry a failed channel order export")
|
|
737
749
|
.permission("channels:manage")
|
package/dist/schema.d.ts
CHANGED
|
@@ -2354,4 +2354,5 @@ export type ChannelCatalogPushEvent = typeof channelCatalogPushEvents.$inferSele
|
|
|
2354
2354
|
export type ChannelOrderExport = typeof channelOrderExports.$inferSelect;
|
|
2355
2355
|
export type ChannelExportEvent = typeof channelExportEvents.$inferSelect;
|
|
2356
2356
|
export type ChannelRefundRequest = typeof channelRefundRequests.$inferSelect;
|
|
2357
|
+
export type ChannelReturn = typeof channelReturns.$inferSelect;
|
|
2357
2358
|
export type ChannelRefundEvent = typeof channelRefundEvents.$inferSelect;
|
package/dist/service.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Actor, ChannelCancelOrderInput, ChannelCatalogItem, ChannelConnect
|
|
|
3
3
|
import type { ChannelCatalogImage } from "@porulle/core";
|
|
4
4
|
import type { FieldOwner, FieldPath } from "@porulle/core";
|
|
5
5
|
import type { JobsAdapter } from "@porulle/core";
|
|
6
|
-
import { type ChannelCatalogPush, type ChannelCatalogConflict, type ChannelOrderExport, type ChannelRefundRequest, type ConnectedStore } from "./schema.js";
|
|
6
|
+
import { type ChannelCatalogPush, type ChannelCatalogConflict, type ChannelOrderExport, type ChannelRefundRequest, type ChannelReturn, type ConnectedStore } from "./schema.js";
|
|
7
7
|
import type { StoreHealth } from "./schema.js";
|
|
8
8
|
import { type CatalogFieldMapping, type CatalogFieldTarget } from "./catalog-field-mapping.js";
|
|
9
9
|
export type ExportState = ChannelOrderExport["state"];
|
|
@@ -31,6 +31,8 @@ export declare const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
|
|
|
31
31
|
export declare const REMOTE_ORDER_REFRESH_MS: number;
|
|
32
32
|
/** How often a merchant's visit may make the plugin call a store to check its health. */
|
|
33
33
|
export declare const STORE_HEALTH_INTERVAL_MS: number;
|
|
34
|
+
/** The `remote_return_id` of a return held on the platform, for a store with no returns of its own. */
|
|
35
|
+
export declare const PLATFORM_RETURN_PREFIX = "platform:";
|
|
34
36
|
export declare const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
|
|
35
37
|
export declare function catalogPushRetryDelayMs(attempts: number): number;
|
|
36
38
|
export interface CatalogPushJobResult extends Record<string, unknown> {
|
|
@@ -752,6 +754,17 @@ export declare class ChannelConnectorService {
|
|
|
752
754
|
remoteReturnId: string;
|
|
753
755
|
status: string;
|
|
754
756
|
}>>;
|
|
757
|
+
/** Returns held on the platform (stores with none of their own) that wait for their merchant. */
|
|
758
|
+
listReturns(orgId: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturn[]>>;
|
|
759
|
+
/**
|
|
760
|
+
* The merchant takes a held return back: the shopper is paid back those lines, then the refund is
|
|
761
|
+
* booked at the store with the stock put back, and kept as an executed refund request under the
|
|
762
|
+
* store's own refund id so the store's webhook for it pays nobody twice. If the store will not book
|
|
763
|
+
* it, the shopper has still been paid and the return stays `approved`; approving again only books it.
|
|
764
|
+
*/
|
|
765
|
+
approveReturn(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturn>>;
|
|
766
|
+
/** The merchant refuses a held return. Nothing moves. */
|
|
767
|
+
declineReturn(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturn>>;
|
|
755
768
|
/** A cancelled or refunded order: nothing to push to a store, ever again. */
|
|
756
769
|
isOrderClosed(orgId: string, orderId: string): Promise<boolean>;
|
|
757
770
|
exportOrder(orgId: string, storeId: string, slice: ChannelOrderSlice, actor: Actor): Promise<PluginResult<ChannelOrderExport>>;
|
package/dist/service.js
CHANGED
|
@@ -37,6 +37,8 @@ export const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
|
|
|
37
37
|
export const REMOTE_ORDER_REFRESH_MS = 5 * 60 * 1000;
|
|
38
38
|
/** How often a merchant's visit may make the plugin call a store to check its health. */
|
|
39
39
|
export const STORE_HEALTH_INTERVAL_MS = 10 * 60 * 1000;
|
|
40
|
+
/** The `remote_return_id` of a return held on the platform, for a store with no returns of its own. */
|
|
41
|
+
export const PLATFORM_RETURN_PREFIX = "platform:";
|
|
40
42
|
export const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
|
|
41
43
|
const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
|
|
42
44
|
const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
|
|
@@ -4416,7 +4418,9 @@ export class ChannelConnectorService {
|
|
|
4416
4418
|
if (!store || store.status !== "connected")
|
|
4417
4419
|
return PluginErr("The store this order went to is not connected.", "NOT_FOUND");
|
|
4418
4420
|
const connector = this.connectors.get(store.provider);
|
|
4419
|
-
|
|
4421
|
+
// A store with no returns of its own (WooCommerce) has them held here, for its merchant to approve.
|
|
4422
|
+
const hosted = connector?.requestReturn === undefined && connector?.recordRefund !== undefined;
|
|
4423
|
+
if (!connector || (!connector.requestReturn && !hosted))
|
|
4420
4424
|
return PluginErr(`Returns are not available for ${store.provider} stores.`, "NOT_IMPLEMENTED");
|
|
4421
4425
|
const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
|
|
4422
4426
|
const variantIds = lines.flatMap((line) => (line.variantId === null ? [] : [line.variantId]));
|
|
@@ -4436,14 +4440,18 @@ export class ChannelConnectorService {
|
|
|
4436
4440
|
return PluginErr(`The store has no record of line ${wanted.orderLineItemId}, so it cannot take it back.`, "CHANNEL_MAPPING_MISSING");
|
|
4437
4441
|
remote.push({ externalVariantId: externalId, quantity: wanted.quantity });
|
|
4438
4442
|
}
|
|
4439
|
-
|
|
4440
|
-
if (
|
|
4441
|
-
|
|
4443
|
+
let remoteReturnId = `${PLATFORM_RETURN_PREFIX}${crypto.randomUUID()}`;
|
|
4444
|
+
if (connector.requestReturn) {
|
|
4445
|
+
const asked = await connector.requestReturn(store, exported.remoteOrderId, { lines: remote, reason: input.reason, ...(input.note ? { note: input.note } : {}) });
|
|
4446
|
+
if (!asked.ok)
|
|
4447
|
+
return PluginErr(asked.error.message, asked.error.code);
|
|
4448
|
+
remoteReturnId = asked.value.remoteReturnId;
|
|
4449
|
+
}
|
|
4442
4450
|
const [row] = await this.db.insert(channelReturns).values({
|
|
4443
4451
|
organizationId: orgId,
|
|
4444
4452
|
storeId: store.id,
|
|
4445
4453
|
orderId,
|
|
4446
|
-
remoteReturnId
|
|
4454
|
+
remoteReturnId,
|
|
4447
4455
|
status: "requested",
|
|
4448
4456
|
lines: input.lines,
|
|
4449
4457
|
reason: input.reason,
|
|
@@ -4453,6 +4461,86 @@ export class ChannelConnectorService {
|
|
|
4453
4461
|
return PluginErr("The return could not be recorded.");
|
|
4454
4462
|
return Ok(row);
|
|
4455
4463
|
}
|
|
4464
|
+
/** Returns held on the platform (stores with none of their own) that wait for their merchant. */
|
|
4465
|
+
async listReturns(orgId, context) {
|
|
4466
|
+
const allowed = await this.allowedStores(orgId, context);
|
|
4467
|
+
if (allowed !== null && allowed.length === 0)
|
|
4468
|
+
return Ok([]);
|
|
4469
|
+
const rows = await this.db.select().from(channelReturns).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.status, "requested"), sql `${channelReturns.remoteReturnId} like ${`${PLATFORM_RETURN_PREFIX}%`}`, ...(allowed === null ? [] : [inArray(channelReturns.storeId, [...allowed])]))).orderBy(desc(channelReturns.createdAt));
|
|
4470
|
+
return Ok(rows);
|
|
4471
|
+
}
|
|
4472
|
+
/**
|
|
4473
|
+
* The merchant takes a held return back: the shopper is paid back those lines, then the refund is
|
|
4474
|
+
* booked at the store with the stock put back, and kept as an executed refund request under the
|
|
4475
|
+
* store's own refund id so the store's webhook for it pays nobody twice. If the store will not book
|
|
4476
|
+
* it, the shopper has still been paid and the return stays `approved`; approving again only books it.
|
|
4477
|
+
*/
|
|
4478
|
+
async approveReturn(orgId, id, context) {
|
|
4479
|
+
const [held] = await this.db.select().from(channelReturns).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, id)));
|
|
4480
|
+
if (!held || !held.remoteReturnId.startsWith(PLATFORM_RETURN_PREFIX))
|
|
4481
|
+
return PluginErr("Return not found.", "NOT_FOUND");
|
|
4482
|
+
const reached = await this.reachableStore(orgId, held.storeId, context);
|
|
4483
|
+
if (!reached.ok)
|
|
4484
|
+
return PluginErr("Return not found.", "NOT_FOUND");
|
|
4485
|
+
if (held.status !== "requested" && held.status !== "approved")
|
|
4486
|
+
return PluginErr(`This return is already ${held.status}.`, "CONFLICT");
|
|
4487
|
+
const store = reached.value;
|
|
4488
|
+
const connector = this.connectors.get(store.provider);
|
|
4489
|
+
if (!connector?.recordRefund)
|
|
4490
|
+
return PluginErr(`Returns are not available for ${store.provider} stores.`, "NOT_IMPLEMENTED");
|
|
4491
|
+
const [exported] = await this.db.select({ remoteOrderId: channelOrderExports.remoteOrderId }).from(channelOrderExports)
|
|
4492
|
+
.where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.orderId, held.orderId), eq(channelOrderExports.storeId, store.id)));
|
|
4493
|
+
if (!exported?.remoteOrderId)
|
|
4494
|
+
return PluginErr("This order never reached the store.", "NOT_FOUND");
|
|
4495
|
+
const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, held.orderId));
|
|
4496
|
+
const priced = [];
|
|
4497
|
+
for (const line of held.lines) {
|
|
4498
|
+
const item = orderLines.find((candidate) => candidate.id === line.orderLineItemId);
|
|
4499
|
+
if (!item)
|
|
4500
|
+
return PluginErr(`Line ${line.orderLineItemId} is no longer on this order.`, "VALIDATION_FAILED");
|
|
4501
|
+
priced.push({ lineItemId: item.id, quantity: line.quantity, variantId: item.variantId, amount: Math.round((item.totalPrice + item.taxAmount - item.discountAmount) * line.quantity / item.quantity) });
|
|
4502
|
+
}
|
|
4503
|
+
const actor = createSystemActor(orgId);
|
|
4504
|
+
if (held.status === "requested") {
|
|
4505
|
+
const ordersService = this.services.orders;
|
|
4506
|
+
const refunded = await ordersService.refundLines(held.orderId, { lines: priced.map(({ lineItemId, quantity }) => ({ lineItemId, quantity })), reason: `Return ${held.id}` }, actor);
|
|
4507
|
+
if (!refunded.ok)
|
|
4508
|
+
return PluginErr(refunded.error?.message ?? "The shopper could not be paid back.", "REFUND_FAILED");
|
|
4509
|
+
await this.db.update(channelReturns).set({ status: "approved", updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id)));
|
|
4510
|
+
}
|
|
4511
|
+
const variantIds = priced.flatMap((line) => (line.variantId === null ? [] : [line.variantId]));
|
|
4512
|
+
const mapped = variantIds.length === 0 ? [] : await this.db.select({ variantId: channelEntityMap.variantId, externalId: channelEntityMap.externalId }).from(channelEntityMap)
|
|
4513
|
+
.where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.variantId, variantIds)));
|
|
4514
|
+
const storeLines = [];
|
|
4515
|
+
for (const line of priced) {
|
|
4516
|
+
const externalId = mapped.find((entry) => entry.variantId === line.variantId)?.externalId;
|
|
4517
|
+
if (!externalId)
|
|
4518
|
+
return PluginErr("The shopper was paid back, but the store has no record of a returned line, so it was not booked there.", "CHANNEL_MAPPING_MISSING");
|
|
4519
|
+
storeLines.push({ externalVariantId: externalId, quantity: line.quantity, amount: line.amount });
|
|
4520
|
+
}
|
|
4521
|
+
const amount = storeLines.reduce((sum, line) => sum + line.amount, 0);
|
|
4522
|
+
const booked = await connector.recordRefund(store, exported.remoteOrderId, { lines: storeLines, amount, reason: `Return: ${held.reason}`, restock: true });
|
|
4523
|
+
if (!booked.ok)
|
|
4524
|
+
return PluginErr(`The shopper was paid back, but the store did not record the refund (${booked.error.message}). Approve again to retry.`, "CHANNEL_REFUND_NOT_RECORDED");
|
|
4525
|
+
const lines = priced.map(({ lineItemId, quantity }) => ({ lineItemId, quantity }));
|
|
4526
|
+
await this.db.insert(channelRefundRequests)
|
|
4527
|
+
.values({ organizationId: orgId, storeId: store.id, orderId: held.orderId, remoteRefundId: booked.value.remoteRefundId, amount, lines, state: "executed", approvedBy: requireUserId(actor) })
|
|
4528
|
+
.onConflictDoUpdate({ target: [channelRefundRequests.storeId, channelRefundRequests.remoteRefundId], set: { amount, lines, state: "executed", updatedAt: new Date() } });
|
|
4529
|
+
const [closed] = await this.db.update(channelReturns).set({ status: "closed", updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id))).returning();
|
|
4530
|
+
return closed ? Ok(closed) : PluginErr("Return not found.", "NOT_FOUND");
|
|
4531
|
+
}
|
|
4532
|
+
/** The merchant refuses a held return. Nothing moves. */
|
|
4533
|
+
async declineReturn(orgId, id, context) {
|
|
4534
|
+
const [held] = await this.db.select().from(channelReturns).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, id)));
|
|
4535
|
+
if (!held || !held.remoteReturnId.startsWith(PLATFORM_RETURN_PREFIX))
|
|
4536
|
+
return PluginErr("Return not found.", "NOT_FOUND");
|
|
4537
|
+
const reached = await this.reachableStore(orgId, held.storeId, context);
|
|
4538
|
+
if (!reached.ok)
|
|
4539
|
+
return PluginErr("Return not found.", "NOT_FOUND");
|
|
4540
|
+
const [declined] = await this.db.update(channelReturns).set({ status: "declined", updatedAt: new Date() })
|
|
4541
|
+
.where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id), eq(channelReturns.status, "requested"))).returning();
|
|
4542
|
+
return declined ? Ok(declined) : PluginErr(`This return is already ${held.status}.`, "CONFLICT");
|
|
4543
|
+
}
|
|
4456
4544
|
/** A cancelled or refunded order: nothing to push to a store, ever again. */
|
|
4457
4545
|
async isOrderClosed(orgId, orderId) {
|
|
4458
4546
|
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/plugin-channel-connector",
|
|
3
|
-
"version": "0.
|
|
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/schema.ts
CHANGED
|
@@ -328,4 +328,5 @@ export type ChannelCatalogPushEvent = typeof channelCatalogPushEvents.$inferSele
|
|
|
328
328
|
export type ChannelOrderExport = typeof channelOrderExports.$inferSelect;
|
|
329
329
|
export type ChannelExportEvent = typeof channelExportEvents.$inferSelect;
|
|
330
330
|
export type ChannelRefundRequest = typeof channelRefundRequests.$inferSelect;
|
|
331
|
+
export type ChannelReturn = typeof channelReturns.$inferSelect;
|
|
331
332
|
export type ChannelRefundEvent = typeof channelRefundEvents.$inferSelect;
|
package/src/service.ts
CHANGED
|
@@ -94,6 +94,7 @@ import {
|
|
|
94
94
|
type ChannelCatalogConflict,
|
|
95
95
|
type ChannelOrderExport,
|
|
96
96
|
type ChannelRefundRequest,
|
|
97
|
+
type ChannelReturn,
|
|
97
98
|
type ConnectedStore,
|
|
98
99
|
} from "./schema.js";
|
|
99
100
|
import type { StoreHealth } from "./schema.js";
|
|
@@ -142,6 +143,8 @@ export const REMOTE_ORDER_REFRESH_MS = 5 * 60 * 1000;
|
|
|
142
143
|
|
|
143
144
|
/** How often a merchant's visit may make the plugin call a store to check its health. */
|
|
144
145
|
export const STORE_HEALTH_INTERVAL_MS = 10 * 60 * 1000;
|
|
146
|
+
/** The `remote_return_id` of a return held on the platform, for a store with no returns of its own. */
|
|
147
|
+
export const PLATFORM_RETURN_PREFIX = "platform:";
|
|
145
148
|
|
|
146
149
|
export const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
|
|
147
150
|
|
|
@@ -5582,7 +5585,9 @@ export class ChannelConnectorService {
|
|
|
5582
5585
|
const store = await this.getStoreRecord(orgId, exported.storeId);
|
|
5583
5586
|
if (!store || store.status !== "connected") return PluginErr("The store this order went to is not connected.", "NOT_FOUND");
|
|
5584
5587
|
const connector = this.connectors.get(store.provider);
|
|
5585
|
-
|
|
5588
|
+
// A store with no returns of its own (WooCommerce) has them held here, for its merchant to approve.
|
|
5589
|
+
const hosted = connector?.requestReturn === undefined && connector?.recordRefund !== undefined;
|
|
5590
|
+
if (!connector || (!connector.requestReturn && !hosted)) return PluginErr(`Returns are not available for ${store.provider} stores.`, "NOT_IMPLEMENTED");
|
|
5586
5591
|
|
|
5587
5592
|
const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
|
|
5588
5593
|
const variantIds = lines.flatMap((line) => (line.variantId === null ? [] : [line.variantId]));
|
|
@@ -5600,13 +5605,17 @@ export class ChannelConnectorService {
|
|
|
5600
5605
|
remote.push({ externalVariantId: externalId, quantity: wanted.quantity });
|
|
5601
5606
|
}
|
|
5602
5607
|
|
|
5603
|
-
|
|
5604
|
-
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
|
+
}
|
|
5605
5614
|
const [row] = await this.db.insert(channelReturns).values({
|
|
5606
5615
|
organizationId: orgId,
|
|
5607
5616
|
storeId: store.id,
|
|
5608
5617
|
orderId,
|
|
5609
|
-
remoteReturnId
|
|
5618
|
+
remoteReturnId,
|
|
5610
5619
|
status: "requested",
|
|
5611
5620
|
lines: input.lines,
|
|
5612
5621
|
reason: input.reason,
|
|
@@ -5616,6 +5625,84 @@ export class ChannelConnectorService {
|
|
|
5616
5625
|
return Ok(row);
|
|
5617
5626
|
}
|
|
5618
5627
|
|
|
5628
|
+
/** Returns held on the platform (stores with none of their own) that wait for their merchant. */
|
|
5629
|
+
async listReturns(orgId: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturn[]>> {
|
|
5630
|
+
const allowed = await this.allowedStores(orgId, context);
|
|
5631
|
+
if (allowed !== null && allowed.length === 0) return Ok([]);
|
|
5632
|
+
const rows = await this.db.select().from(channelReturns).where(and(
|
|
5633
|
+
eq(channelReturns.organizationId, orgId),
|
|
5634
|
+
eq(channelReturns.status, "requested"),
|
|
5635
|
+
sql`${channelReturns.remoteReturnId} like ${`${PLATFORM_RETURN_PREFIX}%`}`,
|
|
5636
|
+
...(allowed === null ? [] : [inArray(channelReturns.storeId, [...allowed])]),
|
|
5637
|
+
)).orderBy(desc(channelReturns.createdAt));
|
|
5638
|
+
return Ok(rows);
|
|
5639
|
+
}
|
|
5640
|
+
|
|
5641
|
+
/**
|
|
5642
|
+
* The merchant takes a held return back: the shopper is paid back those lines, then the refund is
|
|
5643
|
+
* booked at the store with the stock put back, and kept as an executed refund request under the
|
|
5644
|
+
* store's own refund id so the store's webhook for it pays nobody twice. If the store will not book
|
|
5645
|
+
* it, the shopper has still been paid and the return stays `approved`; approving again only books it.
|
|
5646
|
+
*/
|
|
5647
|
+
async approveReturn(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturn>> {
|
|
5648
|
+
const [held] = await this.db.select().from(channelReturns).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, id)));
|
|
5649
|
+
if (!held || !held.remoteReturnId.startsWith(PLATFORM_RETURN_PREFIX)) return PluginErr("Return not found.", "NOT_FOUND");
|
|
5650
|
+
const reached = await this.reachableStore(orgId, held.storeId, context);
|
|
5651
|
+
if (!reached.ok) return PluginErr("Return not found.", "NOT_FOUND");
|
|
5652
|
+
if (held.status !== "requested" && held.status !== "approved") return PluginErr(`This return is already ${held.status}.`, "CONFLICT");
|
|
5653
|
+
const store = reached.value;
|
|
5654
|
+
const connector = this.connectors.get(store.provider);
|
|
5655
|
+
if (!connector?.recordRefund) return PluginErr(`Returns are not available for ${store.provider} stores.`, "NOT_IMPLEMENTED");
|
|
5656
|
+
const [exported] = await this.db.select({ remoteOrderId: channelOrderExports.remoteOrderId }).from(channelOrderExports)
|
|
5657
|
+
.where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.orderId, held.orderId), eq(channelOrderExports.storeId, store.id)));
|
|
5658
|
+
if (!exported?.remoteOrderId) return PluginErr("This order never reached the store.", "NOT_FOUND");
|
|
5659
|
+
|
|
5660
|
+
const orderLines = await this.db.select().from(orderLineItems).where(eq(orderLineItems.orderId, held.orderId));
|
|
5661
|
+
const priced: Array<{ lineItemId: string; quantity: number; variantId: string | null; amount: number }> = [];
|
|
5662
|
+
for (const line of held.lines) {
|
|
5663
|
+
const item = orderLines.find((candidate) => candidate.id === line.orderLineItemId);
|
|
5664
|
+
if (!item) return PluginErr(`Line ${line.orderLineItemId} is no longer on this order.`, "VALIDATION_FAILED");
|
|
5665
|
+
priced.push({ lineItemId: item.id, quantity: line.quantity, variantId: item.variantId, amount: Math.round((item.totalPrice + item.taxAmount - item.discountAmount) * line.quantity / item.quantity) });
|
|
5666
|
+
}
|
|
5667
|
+
const actor = createSystemActor(orgId);
|
|
5668
|
+
if (held.status === "requested") {
|
|
5669
|
+
const ordersService = this.services.orders as { refundLines(orderId: string, input: { lines: Array<{ lineItemId: string; quantity: number }>; reason?: string }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }> };
|
|
5670
|
+
const refunded = await ordersService.refundLines(held.orderId, { lines: priced.map(({ lineItemId, quantity }) => ({ lineItemId, quantity })), reason: `Return ${held.id}` }, actor);
|
|
5671
|
+
if (!refunded.ok) return PluginErr(refunded.error?.message ?? "The shopper could not be paid back.", "REFUND_FAILED");
|
|
5672
|
+
await this.db.update(channelReturns).set({ status: "approved", updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id)));
|
|
5673
|
+
}
|
|
5674
|
+
|
|
5675
|
+
const variantIds = priced.flatMap((line) => (line.variantId === null ? [] : [line.variantId]));
|
|
5676
|
+
const mapped = variantIds.length === 0 ? [] : await this.db.select({ variantId: channelEntityMap.variantId, externalId: channelEntityMap.externalId }).from(channelEntityMap)
|
|
5677
|
+
.where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, store.id), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.variantId, variantIds)));
|
|
5678
|
+
const storeLines: Array<{ externalVariantId: string; quantity: number; amount: number }> = [];
|
|
5679
|
+
for (const line of priced) {
|
|
5680
|
+
const externalId = mapped.find((entry) => entry.variantId === line.variantId)?.externalId;
|
|
5681
|
+
if (!externalId) return PluginErr("The shopper was paid back, but the store has no record of a returned line, so it was not booked there.", "CHANNEL_MAPPING_MISSING");
|
|
5682
|
+
storeLines.push({ externalVariantId: externalId, quantity: line.quantity, amount: line.amount });
|
|
5683
|
+
}
|
|
5684
|
+
const amount = storeLines.reduce((sum, line) => sum + line.amount, 0);
|
|
5685
|
+
const booked = await connector.recordRefund(store as ChannelStore, exported.remoteOrderId, { lines: storeLines, amount, reason: `Return: ${held.reason}`, restock: true });
|
|
5686
|
+
if (!booked.ok) return PluginErr(`The shopper was paid back, but the store did not record the refund (${booked.error.message}). Approve again to retry.`, "CHANNEL_REFUND_NOT_RECORDED");
|
|
5687
|
+
const lines = priced.map(({ lineItemId, quantity }) => ({ lineItemId, quantity }));
|
|
5688
|
+
await this.db.insert(channelRefundRequests)
|
|
5689
|
+
.values({ organizationId: orgId, storeId: store.id, orderId: held.orderId, remoteRefundId: booked.value.remoteRefundId, amount, lines, state: "executed", approvedBy: requireUserId(actor) })
|
|
5690
|
+
.onConflictDoUpdate({ target: [channelRefundRequests.storeId, channelRefundRequests.remoteRefundId], set: { amount, lines, state: "executed", updatedAt: new Date() } });
|
|
5691
|
+
const [closed] = await this.db.update(channelReturns).set({ status: "closed", updatedAt: new Date() }).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id))).returning();
|
|
5692
|
+
return closed ? Ok(closed) : PluginErr("Return not found.", "NOT_FOUND");
|
|
5693
|
+
}
|
|
5694
|
+
|
|
5695
|
+
/** The merchant refuses a held return. Nothing moves. */
|
|
5696
|
+
async declineReturn(orgId: string, id: string, context?: StoreReadContext): Promise<PluginResult<ChannelReturn>> {
|
|
5697
|
+
const [held] = await this.db.select().from(channelReturns).where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, id)));
|
|
5698
|
+
if (!held || !held.remoteReturnId.startsWith(PLATFORM_RETURN_PREFIX)) return PluginErr("Return not found.", "NOT_FOUND");
|
|
5699
|
+
const reached = await this.reachableStore(orgId, held.storeId, context);
|
|
5700
|
+
if (!reached.ok) return PluginErr("Return not found.", "NOT_FOUND");
|
|
5701
|
+
const [declined] = await this.db.update(channelReturns).set({ status: "declined", updatedAt: new Date() })
|
|
5702
|
+
.where(and(eq(channelReturns.organizationId, orgId), eq(channelReturns.id, held.id), eq(channelReturns.status, "requested"))).returning();
|
|
5703
|
+
return declined ? Ok(declined) : PluginErr(`This return is already ${held.status}.`, "CONFLICT");
|
|
5704
|
+
}
|
|
5705
|
+
|
|
5619
5706
|
/** A cancelled or refunded order: nothing to push to a store, ever again. */
|
|
5620
5707
|
async isOrderClosed(orgId: string, orderId: string): Promise<boolean> {
|
|
5621
5708
|
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|