@porulle/plugin-channel-connector 0.68.4 → 0.70.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 +23 -1
- package/dist/service.js +131 -5
- package/package.json +4 -4
- package/src/hooks.ts +54 -2
- package/src/index.ts +2 -0
- package/src/live-credentials.ts +1 -0
- package/src/service.ts +131 -4
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,23 @@ 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
|
+
/**
|
|
668
|
+
* One core fulfilment record per store fulfilment the order body carries, keyed on the store's
|
|
669
|
+
* fulfilment id (`metadata.channelFulfillmentId`) so a replay records nothing twice. Each records
|
|
670
|
+
* the lines it shipped, matched by the store's variant id; one whose lines cannot be matched
|
|
671
|
+
* records every line not yet fulfilled. A fulfilment the store cancelled is not a parcel.
|
|
672
|
+
*/
|
|
673
|
+
private recordChannelFulfillments;
|
|
674
|
+
/** Every line with quantity still to ship, for a parcel whose own lines could not be matched. */
|
|
675
|
+
private unfulfilledLines;
|
|
676
|
+
/** A cancelled or refunded order: nothing to push to a store, ever again. */
|
|
677
|
+
isOrderClosed(orgId: string, orderId: string): Promise<boolean>;
|
|
656
678
|
exportOrder(orgId: string, storeId: string, slice: ChannelOrderSlice, actor: Actor): Promise<PluginResult<ChannelOrderExport>>;
|
|
657
679
|
buildOrderSlice(orgId: string, storeId: string, orderId: string): Promise<PluginResult<ChannelOrderSlice>>;
|
|
658
680
|
reapExports(input: {
|
package/dist/service.js
CHANGED
|
@@ -5,7 +5,7 @@ import { isValidFieldPath, requireUserId } from "@porulle/core";
|
|
|
5
5
|
import { CHANNEL_CONVERGENCE_CTX } from "./catalog-push-trigger.js";
|
|
6
6
|
import { resolveLiveCredentials, withLiveCredentials } from "./live-credentials.js";
|
|
7
7
|
import { and, desc, eq, inArray, isNull, lte, or, sql } from "@porulle/core/drizzle";
|
|
8
|
-
import { brands, categories, customerAddresses, customers, entityBrands, entityCategories, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
|
|
8
|
+
import { brands, categories, customerAddresses, customers, entityBrands, entityCategories, entityMedia, entityTags, inventoryLevels, mediaAssets, optionTypes, optionValues, fulfillmentLineItems, fulfillmentRecords, orderLineItems, orders, prices, sellableAttributes, sellableCustomFields, sellableEntities, sellableEntityRevisions, entityFieldDefinitions, tags, variants, variantOptionValues, } from "@porulle/core/schema";
|
|
9
9
|
import { planAbsentArchives } from "./deletion-policy.js";
|
|
10
10
|
import { channelCatalogPushEvents, channelCatalogPushes, channelCatalogConflicts, channelCatalogConflictEvents, channelEntityLinks, channelEntityMap, channelExportEvents, channelOrderExports, connectedStores, channelRefundEvents, channelRefundRequests, } from "./schema.js";
|
|
11
11
|
import { mergeCatalogFieldMapping, normalizeCatalogFieldMapping, selectCatalogFieldMapping, } from "./catalog-field-mapping.js";
|
|
@@ -29,6 +29,20 @@ 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";
|
|
37
|
+
/** The slice of a store order body's `fulfillments` (Shopify's REST spelling) a parcel is read from. */
|
|
38
|
+
const channelFulfillmentsSchema = z.array(z.object({
|
|
39
|
+
id: z.union([z.string(), z.number()]),
|
|
40
|
+
status: z.string().nullish(),
|
|
41
|
+
tracking_company: z.string().nullish(),
|
|
42
|
+
tracking_number: z.string().nullish(),
|
|
43
|
+
tracking_url: z.string().nullish(),
|
|
44
|
+
line_items: z.array(z.object({ variant_id: z.union([z.string(), z.number()]).nullish(), quantity: z.number().int().positive() })).default([]),
|
|
45
|
+
}));
|
|
32
46
|
const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
|
|
33
47
|
const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
|
|
34
48
|
export function catalogPushRetryDelayMs(attempts) {
|
|
@@ -2269,6 +2283,7 @@ export class ChannelConnectorService {
|
|
|
2269
2283
|
"products/delete",
|
|
2270
2284
|
"inventory_levels/update",
|
|
2271
2285
|
"orders/fulfilled",
|
|
2286
|
+
"orders/partially_fulfilled",
|
|
2272
2287
|
"orders/cancelled",
|
|
2273
2288
|
"app/uninstalled",
|
|
2274
2289
|
], callbackUrl);
|
|
@@ -3875,20 +3890,35 @@ export class ChannelConnectorService {
|
|
|
3875
3890
|
await this.setMappedInventory(orgId, storeId, externalId, available, actor);
|
|
3876
3891
|
}
|
|
3877
3892
|
}
|
|
3878
|
-
else if (event.type === "orders/fulfilled" || event.type === "orders/cancelled") {
|
|
3893
|
+
else if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled" || event.type === "orders/cancelled") {
|
|
3879
3894
|
const orderId = await this.resolveOrderId(orgId, storeId, data);
|
|
3880
3895
|
if (orderId) {
|
|
3881
3896
|
const ordersService = this.services.orders;
|
|
3882
3897
|
const note = await ordersService.addNote(orderId, { body: `Channel ${event.type}: ${String(data.id ?? data.order_id ?? "remote order")}.` }, actor);
|
|
3883
3898
|
if (!note.ok)
|
|
3884
3899
|
return PluginErr(note.error?.message ?? "Could not add channel order note.");
|
|
3885
|
-
if (event.type === "orders/
|
|
3900
|
+
if (event.type === "orders/cancelled") {
|
|
3901
|
+
// The store cancelled: the platform follows, under a reason the cancel hook recognises, so
|
|
3902
|
+
// it does not turn round and cancel at the store again. An order already closed, or one the
|
|
3903
|
+
// machine cannot cancel (shipped), keeps its status; the note above records the delivery.
|
|
3904
|
+
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
3905
|
+
if (order && !["cancelled", "refunded"].includes(order.status)) {
|
|
3906
|
+
await ordersService.changeStatus({ orderId, newStatus: "cancelled", reason: CHANNEL_ORDER_CANCELLED_REASON }, actor);
|
|
3907
|
+
}
|
|
3908
|
+
}
|
|
3909
|
+
if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled") {
|
|
3910
|
+
// The parcels first, so whatever the status move announces (a shipped email) can read them.
|
|
3911
|
+
const recorded = await this.recordChannelFulfillments(orgId, storeId, orderId, data, actor);
|
|
3912
|
+
if (!recorded.ok)
|
|
3913
|
+
return recorded;
|
|
3914
|
+
const target = event.type === "orders/fulfilled" ? "fulfilled" : "partially_fulfilled";
|
|
3886
3915
|
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
3887
3916
|
if (order?.status === "confirmed")
|
|
3888
3917
|
await ordersService.changeStatus({ orderId, newStatus: "processing", reason: "channel_order_fulfilled" }, actor);
|
|
3889
3918
|
const [after] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
3890
|
-
if (after?.status === "processing")
|
|
3891
|
-
await ordersService.changeStatus({ orderId, newStatus:
|
|
3919
|
+
if (after?.status === "processing" || (target === "fulfilled" && after?.status === "partially_fulfilled")) {
|
|
3920
|
+
await ordersService.changeStatus({ orderId, newStatus: target, reason: "channel_order_fulfilled" }, actor);
|
|
3921
|
+
}
|
|
3892
3922
|
}
|
|
3893
3923
|
}
|
|
3894
3924
|
}
|
|
@@ -4283,6 +4313,102 @@ export class ChannelConnectorService {
|
|
|
4283
4313
|
return Ok(updated);
|
|
4284
4314
|
});
|
|
4285
4315
|
}
|
|
4316
|
+
/**
|
|
4317
|
+
* Cancels this order at every store it was pushed to. Any refusal is returned as the error, so the
|
|
4318
|
+
* caller can refuse its own cancel: a store that has shipped must not see the marketplace refund
|
|
4319
|
+
* goods already on their way. A store with no connector able to cancel is left to its merchant.
|
|
4320
|
+
*/
|
|
4321
|
+
async cancelRemoteOrders(orgId, orderId, input) {
|
|
4322
|
+
const exports = await this.db
|
|
4323
|
+
.select({ storeId: channelOrderExports.storeId, remoteOrderId: channelOrderExports.remoteOrderId })
|
|
4324
|
+
.from(channelOrderExports)
|
|
4325
|
+
.where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.orderId, orderId)));
|
|
4326
|
+
let cancelled = 0;
|
|
4327
|
+
for (const exported of exports) {
|
|
4328
|
+
if (exported.remoteOrderId === null)
|
|
4329
|
+
continue;
|
|
4330
|
+
const store = await this.getStoreRecord(orgId, exported.storeId);
|
|
4331
|
+
if (!store || store.status !== "connected")
|
|
4332
|
+
continue;
|
|
4333
|
+
// ponytail: a provider without cancelOrder is skipped silently; refuse instead if one ever ships without it.
|
|
4334
|
+
const connector = this.connectors.get(store.provider);
|
|
4335
|
+
if (!connector?.cancelOrder)
|
|
4336
|
+
continue;
|
|
4337
|
+
const result = await connector.cancelOrder(store, exported.remoteOrderId, input);
|
|
4338
|
+
if (!result.ok)
|
|
4339
|
+
return PluginErr(result.error.message, result.error.code);
|
|
4340
|
+
cancelled += 1;
|
|
4341
|
+
}
|
|
4342
|
+
return Ok(cancelled);
|
|
4343
|
+
}
|
|
4344
|
+
/**
|
|
4345
|
+
* One core fulfilment record per store fulfilment the order body carries, keyed on the store's
|
|
4346
|
+
* fulfilment id (`metadata.channelFulfillmentId`) so a replay records nothing twice. Each records
|
|
4347
|
+
* the lines it shipped, matched by the store's variant id; one whose lines cannot be matched
|
|
4348
|
+
* records every line not yet fulfilled. A fulfilment the store cancelled is not a parcel.
|
|
4349
|
+
*/
|
|
4350
|
+
async recordChannelFulfillments(orgId, storeId, orderId, data, actor) {
|
|
4351
|
+
const parsed = channelFulfillmentsSchema.safeParse(data.fulfillments ?? []);
|
|
4352
|
+
if (!parsed.success)
|
|
4353
|
+
return PluginErr(`Channel order fulfilments did not parse: ${parsed.error.message}`);
|
|
4354
|
+
const existing = await this.db.select({ metadata: fulfillmentRecords.metadata }).from(fulfillmentRecords).where(eq(fulfillmentRecords.orderId, orderId));
|
|
4355
|
+
const recorded = new Set(existing.map((row) => String(row.metadata?.channelFulfillmentId ?? "")));
|
|
4356
|
+
const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
|
|
4357
|
+
const fulfillment = this.services.fulfillment;
|
|
4358
|
+
let created = 0;
|
|
4359
|
+
for (const parcel of parsed.data) {
|
|
4360
|
+
const parcelId = String(parcel.id);
|
|
4361
|
+
if (recorded.has(parcelId) || parcel.status === "cancelled" || parcel.status === "error" || parcel.status === "failure")
|
|
4362
|
+
continue;
|
|
4363
|
+
const externalIds = parcel.line_items.flatMap((line) => (line.variant_id == null ? [] : [String(line.variant_id)]));
|
|
4364
|
+
const mapped = externalIds.length === 0 ? [] : await this.db
|
|
4365
|
+
.select({ externalId: channelEntityMap.externalId, variantId: channelEntityMap.variantId })
|
|
4366
|
+
.from(channelEntityMap)
|
|
4367
|
+
.where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.externalId, externalIds)));
|
|
4368
|
+
const variantFor = new Map(mapped.map((row) => [row.externalId, row.variantId]));
|
|
4369
|
+
const matched = parcel.line_items.flatMap((line) => {
|
|
4370
|
+
const variantId = line.variant_id == null ? undefined : variantFor.get(String(line.variant_id));
|
|
4371
|
+
const orderLine = variantId == null ? undefined : lines.find((candidate) => candidate.variantId === variantId);
|
|
4372
|
+
return orderLine ? [{ orderLineItemId: orderLine.id, quantity: line.quantity }] : [];
|
|
4373
|
+
});
|
|
4374
|
+
const lineItems = matched.length > 0 ? matched : await this.unfulfilledLines(lines);
|
|
4375
|
+
if (lineItems.length === 0)
|
|
4376
|
+
continue;
|
|
4377
|
+
const result = await fulfillment.createFulfillment({
|
|
4378
|
+
orderId,
|
|
4379
|
+
lineItems,
|
|
4380
|
+
...(parcel.tracking_company ? { carrier: parcel.tracking_company } : {}),
|
|
4381
|
+
...(parcel.tracking_number ? { trackingNumber: parcel.tracking_number } : {}),
|
|
4382
|
+
...(parcel.tracking_url ? { trackingUrl: parcel.tracking_url } : {}),
|
|
4383
|
+
status: "shipped",
|
|
4384
|
+
metadata: { channelFulfillmentId: parcelId, storeId },
|
|
4385
|
+
}, actor);
|
|
4386
|
+
if (!result.ok)
|
|
4387
|
+
return PluginErr(result.error?.message ?? "Could not record the store's fulfilment.");
|
|
4388
|
+
recorded.add(parcelId);
|
|
4389
|
+
created += 1;
|
|
4390
|
+
}
|
|
4391
|
+
return Ok(created);
|
|
4392
|
+
}
|
|
4393
|
+
/** Every line with quantity still to ship, for a parcel whose own lines could not be matched. */
|
|
4394
|
+
async unfulfilledLines(lines) {
|
|
4395
|
+
const ids = lines.map((line) => line.id);
|
|
4396
|
+
const shipped = ids.length === 0 ? [] : await this.db
|
|
4397
|
+
.select({ lineId: fulfillmentLineItems.orderLineItemId, quantity: sql `coalesce(sum(${fulfillmentLineItems.quantity}), 0)::int` })
|
|
4398
|
+
.from(fulfillmentLineItems)
|
|
4399
|
+
.where(inArray(fulfillmentLineItems.orderLineItemId, ids))
|
|
4400
|
+
.groupBy(fulfillmentLineItems.orderLineItemId);
|
|
4401
|
+
const shippedBy = new Map(shipped.map((row) => [row.lineId, row.quantity]));
|
|
4402
|
+
return lines.flatMap((line) => {
|
|
4403
|
+
const remaining = line.quantity - (shippedBy.get(line.id) ?? 0);
|
|
4404
|
+
return remaining > 0 ? [{ orderLineItemId: line.id, quantity: remaining }] : [];
|
|
4405
|
+
});
|
|
4406
|
+
}
|
|
4407
|
+
/** A cancelled or refunded order: nothing to push to a store, ever again. */
|
|
4408
|
+
async isOrderClosed(orgId, orderId) {
|
|
4409
|
+
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
4410
|
+
return order?.status === "cancelled" || order?.status === "refunded";
|
|
4411
|
+
}
|
|
4286
4412
|
async exportOrder(orgId, storeId, slice, actor) {
|
|
4287
4413
|
const store = await this.getStoreRecord(orgId, storeId);
|
|
4288
4414
|
if (!store || store.status !== "connected") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/plugin-channel-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.70.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -22,15 +22,15 @@
|
|
|
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.70.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^24.5.2",
|
|
29
29
|
"eslint": "^9.39.1",
|
|
30
30
|
"typescript": "5.9.2",
|
|
31
31
|
"vitest": "^3.2.4",
|
|
32
|
-
"@porulle/
|
|
33
|
-
"@porulle/
|
|
32
|
+
"@porulle/eslint-config": "0.1.0",
|
|
33
|
+
"@porulle/typescript-config": "0.1.0"
|
|
34
34
|
},
|
|
35
35
|
"publishConfig": {
|
|
36
36
|
"access": "public"
|
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,
|
|
@@ -55,6 +56,8 @@ import {
|
|
|
55
56
|
mediaAssets,
|
|
56
57
|
optionTypes,
|
|
57
58
|
optionValues,
|
|
59
|
+
fulfillmentLineItems,
|
|
60
|
+
fulfillmentRecords,
|
|
58
61
|
orderLineItems,
|
|
59
62
|
orders,
|
|
60
63
|
prices,
|
|
@@ -123,6 +126,22 @@ export const CATALOG_PUSH_MAX_ATTEMPTS = 8;
|
|
|
123
126
|
*/
|
|
124
127
|
export const CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION = 20;
|
|
125
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The reason a platform order is cancelled under when its STORE cancelled it. The cancel hook skips
|
|
131
|
+
* cancelling at the store for exactly this reason, so the two directions cannot loop.
|
|
132
|
+
*/
|
|
133
|
+
export const CHANNEL_ORDER_CANCELLED_REASON = "channel_order_cancelled";
|
|
134
|
+
|
|
135
|
+
/** The slice of a store order body's `fulfillments` (Shopify's REST spelling) a parcel is read from. */
|
|
136
|
+
const channelFulfillmentsSchema = z.array(z.object({
|
|
137
|
+
id: z.union([z.string(), z.number()]),
|
|
138
|
+
status: z.string().nullish(),
|
|
139
|
+
tracking_company: z.string().nullish(),
|
|
140
|
+
tracking_number: z.string().nullish(),
|
|
141
|
+
tracking_url: z.string().nullish(),
|
|
142
|
+
line_items: z.array(z.object({ variant_id: z.union([z.string(), z.number()]).nullish(), quantity: z.number().int().positive() })).default([]),
|
|
143
|
+
}));
|
|
144
|
+
|
|
126
145
|
const CATALOG_PUSH_RETRY_BASE_MS = 60_000;
|
|
127
146
|
const CATALOG_PUSH_RETRY_MAX_MS = 60 * 60 * 1000;
|
|
128
147
|
|
|
@@ -3182,6 +3201,7 @@ export class ChannelConnectorService {
|
|
|
3182
3201
|
"products/delete",
|
|
3183
3202
|
"inventory_levels/update",
|
|
3184
3203
|
"orders/fulfilled",
|
|
3204
|
+
"orders/partially_fulfilled",
|
|
3185
3205
|
"orders/cancelled",
|
|
3186
3206
|
"app/uninstalled",
|
|
3187
3207
|
], callbackUrl);
|
|
@@ -4978,17 +4998,32 @@ export class ChannelConnectorService {
|
|
|
4978
4998
|
const externalId = String(data.variation_id ?? data.product_id ?? inventoryItemId ?? "");
|
|
4979
4999
|
await this.setMappedInventory(orgId, storeId, externalId, available, actor);
|
|
4980
5000
|
}
|
|
4981
|
-
} else if (event.type === "orders/fulfilled" || event.type === "orders/cancelled") {
|
|
5001
|
+
} else if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled" || event.type === "orders/cancelled") {
|
|
4982
5002
|
const orderId = await this.resolveOrderId(orgId, storeId, data);
|
|
4983
5003
|
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 }> };
|
|
5004
|
+
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" | "partially_fulfilled" | "cancelled"; reason: string }, actor: Actor): Promise<{ ok: boolean }> };
|
|
4985
5005
|
const note = await ordersService.addNote(orderId, { body: `Channel ${event.type}: ${String(data.id ?? data.order_id ?? "remote order")}.` }, actor);
|
|
4986
5006
|
if (!note.ok) return PluginErr(note.error?.message ?? "Could not add channel order note.");
|
|
4987
|
-
if (event.type === "orders/
|
|
5007
|
+
if (event.type === "orders/cancelled") {
|
|
5008
|
+
// The store cancelled: the platform follows, under a reason the cancel hook recognises, so
|
|
5009
|
+
// it does not turn round and cancel at the store again. An order already closed, or one the
|
|
5010
|
+
// machine cannot cancel (shipped), keeps its status; the note above records the delivery.
|
|
5011
|
+
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
5012
|
+
if (order && !["cancelled", "refunded"].includes(order.status)) {
|
|
5013
|
+
await ordersService.changeStatus({ orderId, newStatus: "cancelled", reason: CHANNEL_ORDER_CANCELLED_REASON }, actor);
|
|
5014
|
+
}
|
|
5015
|
+
}
|
|
5016
|
+
if (event.type === "orders/fulfilled" || event.type === "orders/partially_fulfilled") {
|
|
5017
|
+
// The parcels first, so whatever the status move announces (a shipped email) can read them.
|
|
5018
|
+
const recorded = await this.recordChannelFulfillments(orgId, storeId, orderId, data, actor);
|
|
5019
|
+
if (!recorded.ok) return recorded;
|
|
5020
|
+
const target = event.type === "orders/fulfilled" ? "fulfilled" : "partially_fulfilled";
|
|
4988
5021
|
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
4989
5022
|
if (order?.status === "confirmed") await ordersService.changeStatus({ orderId, newStatus: "processing", reason: "channel_order_fulfilled" }, actor);
|
|
4990
5023
|
const [after] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
4991
|
-
if (after?.status === "processing"
|
|
5024
|
+
if (after?.status === "processing" || (target === "fulfilled" && after?.status === "partially_fulfilled")) {
|
|
5025
|
+
await ordersService.changeStatus({ orderId, newStatus: target, reason: "channel_order_fulfilled" }, actor);
|
|
5026
|
+
}
|
|
4992
5027
|
}
|
|
4993
5028
|
}
|
|
4994
5029
|
} else if (event.type === "refunds/create") {
|
|
@@ -5429,6 +5464,98 @@ export class ChannelConnectorService {
|
|
|
5429
5464
|
});
|
|
5430
5465
|
}
|
|
5431
5466
|
|
|
5467
|
+
/**
|
|
5468
|
+
* Cancels this order at every store it was pushed to. Any refusal is returned as the error, so the
|
|
5469
|
+
* caller can refuse its own cancel: a store that has shipped must not see the marketplace refund
|
|
5470
|
+
* goods already on their way. A store with no connector able to cancel is left to its merchant.
|
|
5471
|
+
*/
|
|
5472
|
+
async cancelRemoteOrders(orgId: string, orderId: string, input: ChannelCancelOrderInput): Promise<PluginResult<number>> {
|
|
5473
|
+
const exports = await this.db
|
|
5474
|
+
.select({ storeId: channelOrderExports.storeId, remoteOrderId: channelOrderExports.remoteOrderId })
|
|
5475
|
+
.from(channelOrderExports)
|
|
5476
|
+
.where(and(eq(channelOrderExports.organizationId, orgId), eq(channelOrderExports.orderId, orderId)));
|
|
5477
|
+
let cancelled = 0;
|
|
5478
|
+
for (const exported of exports) {
|
|
5479
|
+
if (exported.remoteOrderId === null) continue;
|
|
5480
|
+
const store = await this.getStoreRecord(orgId, exported.storeId);
|
|
5481
|
+
if (!store || store.status !== "connected") continue;
|
|
5482
|
+
// ponytail: a provider without cancelOrder is skipped silently; refuse instead if one ever ships without it.
|
|
5483
|
+
const connector = this.connectors.get(store.provider);
|
|
5484
|
+
if (!connector?.cancelOrder) continue;
|
|
5485
|
+
const result = await connector.cancelOrder(store as ChannelStore, exported.remoteOrderId, input);
|
|
5486
|
+
if (!result.ok) return PluginErr(result.error.message, result.error.code);
|
|
5487
|
+
cancelled += 1;
|
|
5488
|
+
}
|
|
5489
|
+
return Ok(cancelled);
|
|
5490
|
+
}
|
|
5491
|
+
|
|
5492
|
+
/**
|
|
5493
|
+
* One core fulfilment record per store fulfilment the order body carries, keyed on the store's
|
|
5494
|
+
* fulfilment id (`metadata.channelFulfillmentId`) so a replay records nothing twice. Each records
|
|
5495
|
+
* the lines it shipped, matched by the store's variant id; one whose lines cannot be matched
|
|
5496
|
+
* records every line not yet fulfilled. A fulfilment the store cancelled is not a parcel.
|
|
5497
|
+
*/
|
|
5498
|
+
private async recordChannelFulfillments(orgId: string, storeId: string, orderId: string, data: Record<string, unknown>, actor: Actor): Promise<PluginResult<number>> {
|
|
5499
|
+
const parsed = channelFulfillmentsSchema.safeParse(data.fulfillments ?? []);
|
|
5500
|
+
if (!parsed.success) return PluginErr(`Channel order fulfilments did not parse: ${parsed.error.message}`);
|
|
5501
|
+
const existing = await this.db.select({ metadata: fulfillmentRecords.metadata }).from(fulfillmentRecords).where(eq(fulfillmentRecords.orderId, orderId));
|
|
5502
|
+
const recorded = new Set(existing.map((row) => String(row.metadata?.channelFulfillmentId ?? "")));
|
|
5503
|
+
const lines = await this.db.select({ id: orderLineItems.id, variantId: orderLineItems.variantId, quantity: orderLineItems.quantity }).from(orderLineItems).where(eq(orderLineItems.orderId, orderId));
|
|
5504
|
+
const fulfillment = this.services.fulfillment as { createFulfillment(input: { orderId: string; lineItems: Array<{ orderLineItemId: string; quantity: number }>; carrier?: string; trackingNumber?: string; trackingUrl?: string; status?: string; metadata?: Record<string, unknown> }, actor: Actor): Promise<{ ok: boolean; error?: { message: string } }> };
|
|
5505
|
+
let created = 0;
|
|
5506
|
+
for (const parcel of parsed.data) {
|
|
5507
|
+
const parcelId = String(parcel.id);
|
|
5508
|
+
if (recorded.has(parcelId) || parcel.status === "cancelled" || parcel.status === "error" || parcel.status === "failure") continue;
|
|
5509
|
+
const externalIds = parcel.line_items.flatMap((line) => (line.variant_id == null ? [] : [String(line.variant_id)]));
|
|
5510
|
+
const mapped = externalIds.length === 0 ? [] : await this.db
|
|
5511
|
+
.select({ externalId: channelEntityMap.externalId, variantId: channelEntityMap.variantId })
|
|
5512
|
+
.from(channelEntityMap)
|
|
5513
|
+
.where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId), eq(channelEntityMap.kind, "variant"), inArray(channelEntityMap.externalId, externalIds)));
|
|
5514
|
+
const variantFor = new Map(mapped.map((row) => [row.externalId, row.variantId]));
|
|
5515
|
+
const matched = parcel.line_items.flatMap((line) => {
|
|
5516
|
+
const variantId = line.variant_id == null ? undefined : variantFor.get(String(line.variant_id));
|
|
5517
|
+
const orderLine = variantId == null ? undefined : lines.find((candidate) => candidate.variantId === variantId);
|
|
5518
|
+
return orderLine ? [{ orderLineItemId: orderLine.id, quantity: line.quantity }] : [];
|
|
5519
|
+
});
|
|
5520
|
+
const lineItems = matched.length > 0 ? matched : await this.unfulfilledLines(lines);
|
|
5521
|
+
if (lineItems.length === 0) continue;
|
|
5522
|
+
const result = await fulfillment.createFulfillment({
|
|
5523
|
+
orderId,
|
|
5524
|
+
lineItems,
|
|
5525
|
+
...(parcel.tracking_company ? { carrier: parcel.tracking_company } : {}),
|
|
5526
|
+
...(parcel.tracking_number ? { trackingNumber: parcel.tracking_number } : {}),
|
|
5527
|
+
...(parcel.tracking_url ? { trackingUrl: parcel.tracking_url } : {}),
|
|
5528
|
+
status: "shipped",
|
|
5529
|
+
metadata: { channelFulfillmentId: parcelId, storeId },
|
|
5530
|
+
}, actor);
|
|
5531
|
+
if (!result.ok) return PluginErr(result.error?.message ?? "Could not record the store's fulfilment.");
|
|
5532
|
+
recorded.add(parcelId);
|
|
5533
|
+
created += 1;
|
|
5534
|
+
}
|
|
5535
|
+
return Ok(created);
|
|
5536
|
+
}
|
|
5537
|
+
|
|
5538
|
+
/** Every line with quantity still to ship, for a parcel whose own lines could not be matched. */
|
|
5539
|
+
private async unfulfilledLines(lines: Array<{ id: string; quantity: number }>): Promise<Array<{ orderLineItemId: string; quantity: number }>> {
|
|
5540
|
+
const ids = lines.map((line) => line.id);
|
|
5541
|
+
const shipped = ids.length === 0 ? [] : await this.db
|
|
5542
|
+
.select({ lineId: fulfillmentLineItems.orderLineItemId, quantity: sql<number>`coalesce(sum(${fulfillmentLineItems.quantity}), 0)::int` })
|
|
5543
|
+
.from(fulfillmentLineItems)
|
|
5544
|
+
.where(inArray(fulfillmentLineItems.orderLineItemId, ids))
|
|
5545
|
+
.groupBy(fulfillmentLineItems.orderLineItemId);
|
|
5546
|
+
const shippedBy = new Map(shipped.map((row) => [row.lineId, row.quantity]));
|
|
5547
|
+
return lines.flatMap((line) => {
|
|
5548
|
+
const remaining = line.quantity - (shippedBy.get(line.id) ?? 0);
|
|
5549
|
+
return remaining > 0 ? [{ orderLineItemId: line.id, quantity: remaining }] : [];
|
|
5550
|
+
});
|
|
5551
|
+
}
|
|
5552
|
+
|
|
5553
|
+
/** A cancelled or refunded order: nothing to push to a store, ever again. */
|
|
5554
|
+
async isOrderClosed(orgId: string, orderId: string): Promise<boolean> {
|
|
5555
|
+
const [order] = await this.db.select({ status: orders.status }).from(orders).where(and(eq(orders.organizationId, orgId), eq(orders.id, orderId)));
|
|
5556
|
+
return order?.status === "cancelled" || order?.status === "refunded";
|
|
5557
|
+
}
|
|
5558
|
+
|
|
5432
5559
|
async exportOrder(
|
|
5433
5560
|
orgId: string,
|
|
5434
5561
|
storeId: string,
|