@porulle/plugin-channel-connector 0.68.3 → 0.69.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/hooks.js +49 -3
- package/dist/index.js +3 -0
- package/dist/live-credentials.js +1 -0
- package/dist/service.d.ts +14 -1
- package/dist/service.js +51 -1
- package/package.json +2 -2
- package/src/hooks.ts +54 -2
- package/src/index.ts +2 -0
- package/src/live-credentials.ts +1 -0
- package/src/service.ts +52 -2
package/dist/hooks.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { resolveOrgIdForCommerce } from "@porulle/core";
|
|
1
|
+
import { CommerceValidationError, resolveOrgIdForCommerce } from "@porulle/core";
|
|
2
2
|
import { and, eq, inArray } from "@porulle/core/drizzle";
|
|
3
3
|
import { sellableEntities } from "@porulle/core/schema";
|
|
4
|
-
import { ChannelConnectorService, } from "./service.js";
|
|
4
|
+
import { CHANNEL_ORDER_CANCELLED_REASON, ChannelConnectorService, } from "./service.js";
|
|
5
5
|
import { handleCatalogAfterUpdate, recordUpdateFieldPaths, } from "./catalog-push-trigger.js";
|
|
6
6
|
/**
|
|
7
7
|
* The state an order sits in while it waits for a payment gateway. Core owns it
|
|
@@ -157,8 +157,54 @@ function pushHooks(mode) {
|
|
|
157
157
|
}];
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
|
+
function parseCancelRequest(args) {
|
|
161
|
+
if (!isRecord(args))
|
|
162
|
+
return null;
|
|
163
|
+
const { data } = args;
|
|
164
|
+
if (!isRecord(data))
|
|
165
|
+
return null;
|
|
166
|
+
const { orderId, newStatus, reason } = data;
|
|
167
|
+
if (typeof orderId !== "string" || newStatus !== "cancelled")
|
|
168
|
+
return null;
|
|
169
|
+
return { orderId, reason: typeof reason === "string" ? reason : undefined };
|
|
170
|
+
}
|
|
171
|
+
/** The store's reason, read loosely from the platform's free-text one. */
|
|
172
|
+
function storeCancelReason(reason) {
|
|
173
|
+
if (reason === undefined)
|
|
174
|
+
return "other";
|
|
175
|
+
if (/customer|shopper/i.test(reason))
|
|
176
|
+
return "customer";
|
|
177
|
+
if (/stock|inventory/i.test(reason))
|
|
178
|
+
return "inventory";
|
|
179
|
+
return "other";
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Cancel at the store BEFORE the platform cancels, so a store that refuses — it has shipped — blocks
|
|
183
|
+
* the platform's cancel instead of the shopper being refunded for goods on their way. A cancel the
|
|
184
|
+
* store itself started is not sent back to it.
|
|
185
|
+
*/
|
|
186
|
+
function cancelAtStoreHook(options) {
|
|
187
|
+
return {
|
|
188
|
+
key: "orders.beforeStatusChange",
|
|
189
|
+
async handler(args) {
|
|
190
|
+
// A before-hook's return value REPLACES the data, so every path hands it back unchanged.
|
|
191
|
+
if (!isRecord(args))
|
|
192
|
+
throw new Error("orders.beforeStatusChange delivered no payload.");
|
|
193
|
+
const { data } = args;
|
|
194
|
+
const request = parseCancelRequest(args);
|
|
195
|
+
if (request === null || request.reason === CHANNEL_ORDER_CANCELLED_REASON || !hasHookContext(args))
|
|
196
|
+
return data;
|
|
197
|
+
const { context } = args;
|
|
198
|
+
const service = new ChannelConnectorService(context.db, context.services, options);
|
|
199
|
+
const cancelled = await service.cancelRemoteOrders(resolveOrgIdForCommerce(context.actor, context.commerceConfig), request.orderId, { reason: storeCancelReason(request.reason), staffNote: `Cancelled on the marketplace${request.reason ? ` (${request.reason})` : ""}.` });
|
|
200
|
+
if (!cancelled.ok)
|
|
201
|
+
throw new CommerceValidationError(`The store would not cancel this order: ${cancelled.error}`);
|
|
202
|
+
return data;
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
160
206
|
export function buildHooks(options) {
|
|
161
|
-
return [{
|
|
207
|
+
return [cancelAtStoreHook(options), {
|
|
162
208
|
key: "checkout.beforePayment",
|
|
163
209
|
async handler(args) {
|
|
164
210
|
const { data, context } = args;
|
package/dist/index.js
CHANGED
|
@@ -240,6 +240,9 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
240
240
|
const orgId = String(input.orgId);
|
|
241
241
|
const storeId = String(input.storeId);
|
|
242
242
|
const orderId = String(input.orderId);
|
|
243
|
+
// Cancelled before this ran: the store must never receive an order nobody is paying for.
|
|
244
|
+
if (await service.isOrderClosed(orgId, orderId))
|
|
245
|
+
return { output: { state: "skipped", reason: "order closed" } };
|
|
243
246
|
const existing = await service.createExport(orgId, storeId, orderId);
|
|
244
247
|
if (!existing.ok)
|
|
245
248
|
throw new Error(existing.error);
|
package/dist/live-credentials.js
CHANGED
|
@@ -76,5 +76,6 @@ export function withLiveCredentials(connector, db) {
|
|
|
76
76
|
...(connector.pushCatalog ? { pushCatalog: around(connector.pushCatalog) } : {}),
|
|
77
77
|
...(connector.reserve ? { reserve: around(connector.reserve) } : {}),
|
|
78
78
|
...(connector.registerWebhooks ? { registerWebhooks: around(connector.registerWebhooks) } : {}),
|
|
79
|
+
...(connector.cancelOrder ? { cancelOrder: around(connector.cancelOrder) } : {}),
|
|
79
80
|
};
|
|
80
81
|
}
|
package/dist/service.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import type { Actor, ChannelCatalogItem, ChannelConnector, ChannelOrderSlice, ChannelPushCatalogField, ChannelPushCatalogImage, ChannelPushCatalogItem, ChannelPushCatalogItemOutcome, ChannelPushCatalogResult, ChannelStore, PluginDb, PluginResult, PluginTxFn } from "@porulle/core";
|
|
2
|
+
import type { Actor, ChannelCancelOrderInput, ChannelCatalogItem, ChannelConnector, ChannelOrderSlice, ChannelPushCatalogField, ChannelPushCatalogImage, ChannelPushCatalogItem, ChannelPushCatalogItemOutcome, ChannelPushCatalogResult, ChannelStore, PluginDb, PluginResult, PluginTxFn } from "@porulle/core";
|
|
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";
|
|
@@ -22,6 +22,11 @@ export declare const CATALOG_PUSH_MAX_ATTEMPTS = 8;
|
|
|
22
22
|
* nine-hour one that discards everything if it fails.
|
|
23
23
|
*/
|
|
24
24
|
export declare const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
|
|
25
|
+
/**
|
|
26
|
+
* The reason a platform order is cancelled under when its STORE cancelled it. The cancel hook skips
|
|
27
|
+
* cancelling at the store for exactly this reason, so the two directions cannot loop.
|
|
28
|
+
*/
|
|
29
|
+
export declare const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
|
|
25
30
|
export declare function catalogPushRetryDelayMs(attempts: number): number;
|
|
26
31
|
export interface CatalogPushJobResult extends Record<string, unknown> {
|
|
27
32
|
noop?: boolean;
|
|
@@ -653,6 +658,14 @@ export declare class ChannelConnectorService {
|
|
|
653
658
|
private refundLinesForRequest;
|
|
654
659
|
createExport(orgId: string, storeId: string, orderId: string): Promise<PluginResult<ChannelOrderExport>>;
|
|
655
660
|
transitionExport(orgId: string, exportId: string, toState: ExportState, changedBy: string, reason?: string, failureKind?: "definitive" | "transient"): Promise<PluginResult<ChannelOrderExport>>;
|
|
661
|
+
/**
|
|
662
|
+
* Cancels this order at every store it was pushed to. Any refusal is returned as the error, so the
|
|
663
|
+
* caller can refuse its own cancel: a store that has shipped must not see the marketplace refund
|
|
664
|
+
* goods already on their way. A store with no connector able to cancel is left to its merchant.
|
|
665
|
+
*/
|
|
666
|
+
cancelRemoteOrders(orgId: string, orderId: string, input: ChannelCancelOrderInput): Promise<PluginResult<number>>;
|
|
667
|
+
/** A cancelled or refunded order: nothing to push to a store, ever again. */
|
|
668
|
+
isOrderClosed(orgId: string, orderId: string): Promise<boolean>;
|
|
656
669
|
exportOrder(orgId: string, storeId: string, slice: ChannelOrderSlice, actor: Actor): Promise<PluginResult<ChannelOrderExport>>;
|
|
657
670
|
buildOrderSlice(orgId: string, storeId: string, orderId: string): Promise<PluginResult<ChannelOrderSlice>>;
|
|
658
671
|
reapExports(input: {
|
package/dist/service.js
CHANGED
|
@@ -29,6 +29,11 @@ export const CATALOG_PUSH_MAX_ATTEMPTS = 8;
|
|
|
29
29
|
* nine-hour one that discards everything if it fails.
|
|
30
30
|
*/
|
|
31
31
|
export const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
|
|
32
|
+
/**
|
|
33
|
+
* The reason a platform order is cancelled under when its STORE cancelled it. The cancel hook skips
|
|
34
|
+
* cancelling at the store for exactly this reason, so the two directions cannot loop.
|
|
35
|
+
*/
|
|
36
|
+
export const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
|
|
32
37
|
const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
|
|
33
38
|
const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
|
|
34
39
|
export function catalogPushRetryDelayMs(attempts) {
|
|
@@ -3882,6 +3887,15 @@ export class ChannelConnectorService {
|
|
|
3882
3887
|
const note = await ordersService.addNote(orderId, { body: `Channel ${event.type}: ${String(data.id ?? data.order_id ?? "remote order")}.` }, actor);
|
|
3883
3888
|
if (!note.ok)
|
|
3884
3889
|
return PluginErr(note.error?.message ?? "Could not add channel order note.");
|
|
3890
|
+
if (event.type === "orders/cancelled") {
|
|
3891
|
+
// The store cancelled: the platform follows, under a reason the cancel hook recognises, so
|
|
3892
|
+
// it does not turn round and cancel at the store again. An order already closed, or one the
|
|
3893
|
+
// machine cannot cancel (shipped), keeps its status; the note above records the delivery.
|
|
3894
|
+
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
3895
|
+
if (order && !["cancelled", "refunded"].includes(order.status)) {
|
|
3896
|
+
await ordersService.changeStatus({ orderId, newStatus: "cancelled", reason: CHANNEL_ORDER_CANCELLED_REASON }, actor);
|
|
3897
|
+
}
|
|
3898
|
+
}
|
|
3885
3899
|
if (event.type === "orders/fulfilled") {
|
|
3886
3900
|
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
3887
3901
|
if (order?.status === "confirmed")
|
|
@@ -4283,6 +4297,39 @@ export class ChannelConnectorService {
|
|
|
4283
4297
|
return Ok(updated);
|
|
4284
4298
|
});
|
|
4285
4299
|
}
|
|
4300
|
+
/**
|
|
4301
|
+
* Cancels this order at every store it was pushed to. Any refusal is returned as the error, so the
|
|
4302
|
+
* caller can refuse its own cancel: a store that has shipped must not see the marketplace refund
|
|
4303
|
+
* goods already on their way. A store with no connector able to cancel is left to its merchant.
|
|
4304
|
+
*/
|
|
4305
|
+
async cancelRemoteOrders(orgId, orderId, input) {
|
|
4306
|
+
const exports = await this.db
|
|
4307
|
+
.select({ storeId: channelOrderExports.storeId, remoteOrderId: channelOrderExports.remoteOrderId })
|
|
4308
|
+
.from(channelOrderExports)
|
|
4309
|
+
.where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.orderId, orderId)));
|
|
4310
|
+
let cancelled = 0;
|
|
4311
|
+
for (const exported of exports) {
|
|
4312
|
+
if (exported.remoteOrderId === null)
|
|
4313
|
+
continue;
|
|
4314
|
+
const store = await this.getStoreRecord(orgId, exported.storeId);
|
|
4315
|
+
if (!store || store.status !== "connected")
|
|
4316
|
+
continue;
|
|
4317
|
+
// ponytail: a provider without cancelOrder is skipped silently; refuse instead if one ever ships without it.
|
|
4318
|
+
const connector = this.connectors.get(store.provider);
|
|
4319
|
+
if (!connector?.cancelOrder)
|
|
4320
|
+
continue;
|
|
4321
|
+
const result = await connector.cancelOrder(store, exported.remoteOrderId, input);
|
|
4322
|
+
if (!result.ok)
|
|
4323
|
+
return PluginErr(result.error.message, result.error.code);
|
|
4324
|
+
cancelled += 1;
|
|
4325
|
+
}
|
|
4326
|
+
return Ok(cancelled);
|
|
4327
|
+
}
|
|
4328
|
+
/** A cancelled or refunded order: nothing to push to a store, ever again. */
|
|
4329
|
+
async isOrderClosed(orgId, orderId) {
|
|
4330
|
+
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
4331
|
+
return order?.status === "cancelled" || order?.status === "refunded";
|
|
4332
|
+
}
|
|
4286
4333
|
async exportOrder(orgId, storeId, slice, actor) {
|
|
4287
4334
|
const store = await this.getStoreRecord(orgId, storeId);
|
|
4288
4335
|
if (!store || store.status !== "connected") {
|
|
@@ -4387,7 +4434,10 @@ export class ChannelConnectorService {
|
|
|
4387
4434
|
}
|
|
4388
4435
|
if (!email || !shippingAddress)
|
|
4389
4436
|
return PluginErr("Customer email and shipping address are required for channel order export.", "CUSTOMER_DATA_MISSING");
|
|
4390
|
-
|
|
4437
|
+
const linesTotal = lines.reduce((sum, line) => sum + line.totalPrice, 0);
|
|
4438
|
+
// ponytail: a multi-store order sends no shipping to any store; apportion it when such orders exist.
|
|
4439
|
+
const shipping = selected.length === lineItems.length && order.shippingTotal > 0 ? { title: "Shipping", amount: order.shippingTotal } : null;
|
|
4440
|
+
return Ok({ orderId, currency: order.currency, grandTotal: linesTotal + (shipping?.amount ?? 0), lines, ...(shipping ? { shipping } : {}), customer: { name, email, shippingAddress } });
|
|
4391
4441
|
}
|
|
4392
4442
|
async reapExports(input) {
|
|
4393
4443
|
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.69.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.69.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^24.5.2",
|
package/src/hooks.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { resolveOrgIdForCommerce } from "@porulle/core";
|
|
1
|
+
import { CommerceValidationError, resolveOrgIdForCommerce } from "@porulle/core";
|
|
2
2
|
import type {
|
|
3
3
|
Actor,
|
|
4
|
+
ChannelCancelReason,
|
|
4
5
|
CommerceConfig,
|
|
5
6
|
HookContext,
|
|
6
7
|
PluginHookRegistration,
|
|
@@ -8,6 +9,7 @@ import type {
|
|
|
8
9
|
import { and, eq, inArray } from "@porulle/core/drizzle";
|
|
9
10
|
import { sellableEntities } from "@porulle/core/schema";
|
|
10
11
|
import {
|
|
12
|
+
CHANNEL_ORDER_CANCELLED_REASON,
|
|
11
13
|
ChannelConnectorService,
|
|
12
14
|
type ChannelConnectorPluginOptions,
|
|
13
15
|
type ChannelPushTrigger,
|
|
@@ -202,8 +204,58 @@ function pushHooks(mode: ChannelPushTrigger): PluginHookRegistration[] {
|
|
|
202
204
|
}
|
|
203
205
|
}
|
|
204
206
|
|
|
207
|
+
/** The cancel a status change asks for, on `orders.beforeStatusChange`. */
|
|
208
|
+
interface CancelRequest {
|
|
209
|
+
orderId: string;
|
|
210
|
+
reason: string | undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function parseCancelRequest(args: unknown): CancelRequest | null {
|
|
214
|
+
if (!isRecord(args)) return null;
|
|
215
|
+
const { data } = args;
|
|
216
|
+
if (!isRecord(data)) return null;
|
|
217
|
+
const { orderId, newStatus, reason } = data;
|
|
218
|
+
if (typeof orderId !== "string" || newStatus !== "cancelled") return null;
|
|
219
|
+
return { orderId, reason: typeof reason === "string" ? reason : undefined };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The store's reason, read loosely from the platform's free-text one. */
|
|
223
|
+
function storeCancelReason(reason: string | undefined): ChannelCancelReason {
|
|
224
|
+
if (reason === undefined) return "other";
|
|
225
|
+
if (/customer|shopper/i.test(reason)) return "customer";
|
|
226
|
+
if (/stock|inventory/i.test(reason)) return "inventory";
|
|
227
|
+
return "other";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Cancel at the store BEFORE the platform cancels, so a store that refuses — it has shipped — blocks
|
|
232
|
+
* the platform's cancel instead of the shopper being refunded for goods on their way. A cancel the
|
|
233
|
+
* store itself started is not sent back to it.
|
|
234
|
+
*/
|
|
235
|
+
function cancelAtStoreHook(options: ChannelConnectorPluginOptions): PluginHookRegistration {
|
|
236
|
+
return {
|
|
237
|
+
key: "orders.beforeStatusChange",
|
|
238
|
+
async handler(args: unknown) {
|
|
239
|
+
// A before-hook's return value REPLACES the data, so every path hands it back unchanged.
|
|
240
|
+
if (!isRecord(args)) throw new Error("orders.beforeStatusChange delivered no payload.");
|
|
241
|
+
const { data } = args;
|
|
242
|
+
const request = parseCancelRequest(args);
|
|
243
|
+
if (request === null || request.reason === CHANNEL_ORDER_CANCELLED_REASON || !hasHookContext(args)) return data;
|
|
244
|
+
const { context } = args;
|
|
245
|
+
const service = new ChannelConnectorService(context.db, context.services, options);
|
|
246
|
+
const cancelled = await service.cancelRemoteOrders(
|
|
247
|
+
resolveOrgIdForCommerce(context.actor, context.commerceConfig),
|
|
248
|
+
request.orderId,
|
|
249
|
+
{ reason: storeCancelReason(request.reason), staffNote: `Cancelled on the marketplace${request.reason ? ` (${request.reason})` : ""}.` },
|
|
250
|
+
);
|
|
251
|
+
if (!cancelled.ok) throw new CommerceValidationError(`The store would not cancel this order: ${cancelled.error}`);
|
|
252
|
+
return data;
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
205
257
|
export function buildHooks(options: ChannelConnectorPluginOptions): PluginHookRegistration[] {
|
|
206
|
-
return [{
|
|
258
|
+
return [cancelAtStoreHook(options), {
|
|
207
259
|
key: "checkout.beforePayment",
|
|
208
260
|
async handler(args: unknown) {
|
|
209
261
|
const { data, context } = args as {
|
package/src/index.ts
CHANGED
|
@@ -409,6 +409,8 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
409
409
|
const orgId = String(input.orgId);
|
|
410
410
|
const storeId = String(input.storeId);
|
|
411
411
|
const orderId = String(input.orderId);
|
|
412
|
+
// Cancelled before this ran: the store must never receive an order nobody is paying for.
|
|
413
|
+
if (await service.isOrderClosed(orgId, orderId)) return { output: { state: "skipped", reason: "order closed" } };
|
|
412
414
|
const existing = await service.createExport(orgId, storeId, orderId);
|
|
413
415
|
if (!existing.ok) throw new Error(existing.error);
|
|
414
416
|
const slice = await service.buildOrderSlice(orgId, storeId, orderId);
|
package/src/live-credentials.ts
CHANGED
|
@@ -73,5 +73,6 @@ export function withLiveCredentials(connector: ChannelConnector, db: PluginDb):
|
|
|
73
73
|
...(connector.pushCatalog ? { pushCatalog: around(connector.pushCatalog) } : {}),
|
|
74
74
|
...(connector.reserve ? { reserve: around(connector.reserve) } : {}),
|
|
75
75
|
...(connector.registerWebhooks ? { registerWebhooks: around(connector.registerWebhooks) } : {}),
|
|
76
|
+
...(connector.cancelOrder ? { cancelOrder: around(connector.cancelOrder) } : {}),
|
|
76
77
|
};
|
|
77
78
|
}
|
package/src/service.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "@porulle/core";
|
|
15
15
|
import type {
|
|
16
16
|
Actor,
|
|
17
|
+
ChannelCancelOrderInput,
|
|
17
18
|
EntityLinkRows,
|
|
18
19
|
ChannelCatalogItem,
|
|
19
20
|
ChannelConnector,
|
|
@@ -123,6 +124,12 @@ export const CATALOG_PUSH_MAX_ATTEMPTS = 8;
|
|
|
123
124
|
*/
|
|
124
125
|
export const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
|
|
125
126
|
|
|
127
|
+
/**
|
|
128
|
+
* The reason a platform order is cancelled under when its STORE cancelled it. The cancel hook skips
|
|
129
|
+
* cancelling at the store for exactly this reason, so the two directions cannot loop.
|
|
130
|
+
*/
|
|
131
|
+
export const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
|
|
132
|
+
|
|
126
133
|
const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
|
|
127
134
|
const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
|
|
128
135
|
|
|
@@ -4981,9 +4988,18 @@ export class ChannelConnectorService {
|
|
|
4981
4988
|
} else if (event.type === "orders/fulfilled" || event.type === "orders/cancelled") {
|
|
4982
4989
|
const orderId = await this.resolveOrderId(orgId, storeId, data);
|
|
4983
4990
|
if (orderId) {
|
|
4984
|
-
const ordersService = this.services.orders as { addNote(orderId: string, input: { body: string }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }>; changeStatus(input: { orderId: string; newStatus: "processing" | "fulfilled"; reason: string }, actor: Actor): Promise<{ ok: boolean }> };
|
|
4991
|
+
const ordersService = this.services.orders as { addNote(orderId: string, input: { body: string }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }>; changeStatus(input: { orderId: string; newStatus: "processing" | "fulfilled" | "cancelled"; reason: string }, actor: Actor): Promise<{ ok: boolean }> };
|
|
4985
4992
|
const note = await ordersService.addNote(orderId, { body: `Channel ${event.type}: ${String(data.id ?? data.order_id ?? "remote order")}.` }, actor);
|
|
4986
4993
|
if (!note.ok) return PluginErr(note.error?.message ?? "Could not add channel order note.");
|
|
4994
|
+
if (event.type === "orders/cancelled") {
|
|
4995
|
+
// The store cancelled: the platform follows, under a reason the cancel hook recognises, so
|
|
4996
|
+
// it does not turn round and cancel at the store again. An order already closed, or one the
|
|
4997
|
+
// machine cannot cancel (shipped), keeps its status; the note above records the delivery.
|
|
4998
|
+
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
4999
|
+
if (order && !["cancelled", "refunded"].includes(order.status)) {
|
|
5000
|
+
await ordersService.changeStatus({ orderId, newStatus: "cancelled", reason: CHANNEL_ORDER_CANCELLED_REASON }, actor);
|
|
5001
|
+
}
|
|
5002
|
+
}
|
|
4987
5003
|
if (event.type === "orders/fulfilled") {
|
|
4988
5004
|
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
4989
5005
|
if (order?.status === "confirmed") await ordersService.changeStatus({ orderId, newStatus: "processing", reason: "channel_order_fulfilled" }, actor);
|
|
@@ -5429,6 +5445,37 @@ export class ChannelConnectorService {
|
|
|
5429
5445
|
});
|
|
5430
5446
|
}
|
|
5431
5447
|
|
|
5448
|
+
/**
|
|
5449
|
+
* Cancels this order at every store it was pushed to. Any refusal is returned as the error, so the
|
|
5450
|
+
* caller can refuse its own cancel: a store that has shipped must not see the marketplace refund
|
|
5451
|
+
* goods already on their way. A store with no connector able to cancel is left to its merchant.
|
|
5452
|
+
*/
|
|
5453
|
+
async cancelRemoteOrders(orgId: string, orderId: string, input: ChannelCancelOrderInput): Promise<PluginResult<number>> {
|
|
5454
|
+
const exports = await this.db
|
|
5455
|
+
.select({ storeId: channelOrderExports.storeId, remoteOrderId: channelOrderExports.remoteOrderId })
|
|
5456
|
+
.from(channelOrderExports)
|
|
5457
|
+
.where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.orderId, orderId)));
|
|
5458
|
+
let cancelled = 0;
|
|
5459
|
+
for (const exported of exports) {
|
|
5460
|
+
if (exported.remoteOrderId === null) continue;
|
|
5461
|
+
const store = await this.getStoreRecord(orgId, exported.storeId);
|
|
5462
|
+
if (!store || store.status !== "connected") continue;
|
|
5463
|
+
// ponytail: a provider without cancelOrder is skipped silently; refuse instead if one ever ships without it.
|
|
5464
|
+
const connector = this.connectors.get(store.provider);
|
|
5465
|
+
if (!connector?.cancelOrder) continue;
|
|
5466
|
+
const result = await connector.cancelOrder(store as ChannelStore, exported.remoteOrderId, input);
|
|
5467
|
+
if (!result.ok) return PluginErr(result.error.message, result.error.code);
|
|
5468
|
+
cancelled += 1;
|
|
5469
|
+
}
|
|
5470
|
+
return Ok(cancelled);
|
|
5471
|
+
}
|
|
5472
|
+
|
|
5473
|
+
/** A cancelled or refunded order: nothing to push to a store, ever again. */
|
|
5474
|
+
async isOrderClosed(orgId: string, orderId: string): Promise<boolean> {
|
|
5475
|
+
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
5476
|
+
return order?.status === "cancelled" || order?.status === "refunded";
|
|
5477
|
+
}
|
|
5478
|
+
|
|
5432
5479
|
async exportOrder(
|
|
5433
5480
|
orgId: string,
|
|
5434
5481
|
storeId: string,
|
|
@@ -5583,7 +5630,10 @@ export class ChannelConnectorService {
|
|
|
5583
5630
|
shippingAddress = withoutUndefined(parsed.data);
|
|
5584
5631
|
}
|
|
5585
5632
|
if (!email || !shippingAddress) return PluginErr("Customer email and shipping address are required for channel order export.", "CUSTOMER_DATA_MISSING");
|
|
5586
|
-
|
|
5633
|
+
const linesTotal = lines.reduce((sum, line) => sum + line.totalPrice, 0);
|
|
5634
|
+
// ponytail: a multi-store order sends no shipping to any store; apportion it when such orders exist.
|
|
5635
|
+
const shipping = selected.length === lineItems.length && order.shippingTotal > 0 ? { title: "Shipping", amount: order.shippingTotal } : null;
|
|
5636
|
+
return Ok({ orderId, currency: order.currency, grandTotal: linesTotal + (shipping?.amount ?? 0), lines, ...(shipping ? { shipping } : {}), customer: { name, email, shippingAddress } });
|
|
5587
5637
|
}
|
|
5588
5638
|
|
|
5589
5639
|
async reapExports(input: { definitiveMs: number; transientMs: number }): Promise<{ abandonedCount: number; refundedOrderIds: string[] }> {
|