@porulle/plugin-channel-connector 0.38.0 → 0.40.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 +157 -10
- package/dist/service.d.ts +15 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/hooks.ts +194 -14
- package/src/service.ts +16 -0
package/dist/hooks.js
CHANGED
|
@@ -3,6 +3,160 @@ import { and, eq, inArray } from "@porulle/core/drizzle";
|
|
|
3
3
|
import { sellableEntities } from "@porulle/core/schema";
|
|
4
4
|
import { ChannelConnectorService, } from "./service.js";
|
|
5
5
|
import { handleCatalogAfterUpdate, recordUpdateFieldPaths, } from "./catalog-push-trigger.js";
|
|
6
|
+
/**
|
|
7
|
+
* The state an order sits in while it waits for a payment gateway. Core owns it
|
|
8
|
+
* and its machine runs `pending_payment -> confirmed | cancelled`.
|
|
9
|
+
*/
|
|
10
|
+
const PENDING_PAYMENT = "pending_payment";
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return typeof value === "object" && value !== null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Parse the committed order out of a hook payload rather than asserting its
|
|
16
|
+
* shape. The kernel is a trust boundary for this plugin: an order that arrives
|
|
17
|
+
* without an `id`, or whose line items are not the shape we expect, is skipped
|
|
18
|
+
* rather than pushed against a half-read payload.
|
|
19
|
+
*
|
|
20
|
+
* `orders.afterCreate` and `orders.afterStatusChange` both deliver the committed
|
|
21
|
+
* order as `result`, so one parser serves both.
|
|
22
|
+
*/
|
|
23
|
+
function parseOrderResult(args) {
|
|
24
|
+
if (!isRecord(args))
|
|
25
|
+
return null;
|
|
26
|
+
const { result } = args;
|
|
27
|
+
if (!isRecord(result))
|
|
28
|
+
return null;
|
|
29
|
+
const { id, status, lineItems } = result;
|
|
30
|
+
if (typeof id !== "string" || id === "")
|
|
31
|
+
return null;
|
|
32
|
+
const entityIds = Array.isArray(lineItems)
|
|
33
|
+
? lineItems.flatMap((line) => isRecord(line) && typeof line.entityId === "string" ? [line.entityId] : [])
|
|
34
|
+
: [];
|
|
35
|
+
return {
|
|
36
|
+
id,
|
|
37
|
+
status: typeof status === "string" ? status : undefined,
|
|
38
|
+
entityIds,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Parse the transition from a hook payload. `data` carries it on
|
|
43
|
+
* `orders.afterStatusChange`; a payload without both statuses is not a
|
|
44
|
+
* transition this plugin can reason about, so it pushes nothing.
|
|
45
|
+
*/
|
|
46
|
+
function parseStatusTransition(args) {
|
|
47
|
+
if (!isRecord(args))
|
|
48
|
+
return null;
|
|
49
|
+
const { data } = args;
|
|
50
|
+
if (!isRecord(data))
|
|
51
|
+
return null;
|
|
52
|
+
const { fromStatus, newStatus } = data;
|
|
53
|
+
if (typeof fromStatus !== "string" || typeof newStatus !== "string")
|
|
54
|
+
return null;
|
|
55
|
+
return { fromStatus, newStatus };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Narrow a hook payload to one carrying the kernel's own `HookContext`.
|
|
59
|
+
*
|
|
60
|
+
* A type predicate rather than a cast: the capabilities it carries are live
|
|
61
|
+
* objects — a Drizzle handle and a jobs adapter — which cannot be validated by
|
|
62
|
+
* shape, so this checks that they are present and callable and lets the kernel's
|
|
63
|
+
* published type describe them. A payload missing either is skipped rather than
|
|
64
|
+
* pushed against.
|
|
65
|
+
*/
|
|
66
|
+
function hasHookContext(args) {
|
|
67
|
+
if (!isRecord(args))
|
|
68
|
+
return false;
|
|
69
|
+
const { context } = args;
|
|
70
|
+
if (!isRecord(context))
|
|
71
|
+
return false;
|
|
72
|
+
const { db, jobs } = context;
|
|
73
|
+
return (isRecord(db) &&
|
|
74
|
+
isRecord(jobs) &&
|
|
75
|
+
typeof jobs.enqueue === "function");
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Enqueue one `channel/push-order` job per store that owns a line of this order.
|
|
79
|
+
* `concurrencyKey` + `supersedes` collapse repeats on the same order and store.
|
|
80
|
+
*/
|
|
81
|
+
async function pushForOrder(order, context) {
|
|
82
|
+
if (order.entityIds.length === 0)
|
|
83
|
+
return;
|
|
84
|
+
const orgId = resolveOrgIdForCommerce(context.actor, context.commerceConfig);
|
|
85
|
+
const entities = await context.db
|
|
86
|
+
.select({
|
|
87
|
+
id: sellableEntities.id,
|
|
88
|
+
sourceStoreId: sellableEntities.sourceStoreId,
|
|
89
|
+
})
|
|
90
|
+
.from(sellableEntities)
|
|
91
|
+
.where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.id, order.entityIds)));
|
|
92
|
+
const stores = new Set(entities
|
|
93
|
+
.map((entity) => entity.sourceStoreId)
|
|
94
|
+
.filter((storeId) => storeId !== null));
|
|
95
|
+
await Promise.all([...stores].map((storeId) => context.jobs.enqueue("channel/push-order", { orgId, storeId, orderId: order.id }, {
|
|
96
|
+
organizationId: orgId,
|
|
97
|
+
concurrencyKey: `push:${order.id}:${storeId}`,
|
|
98
|
+
supersedes: true,
|
|
99
|
+
})));
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The hooks that trigger the order push, by mode. The switch is exhaustive over
|
|
103
|
+
* `ChannelPushTrigger`, so a fourth mode fails to compile rather than silently
|
|
104
|
+
* registering nothing.
|
|
105
|
+
*
|
|
106
|
+
* `"payment"` registers BOTH hooks on purpose. An order created directly in
|
|
107
|
+
* `pending` — a store with no payment step, which is most consumers — never
|
|
108
|
+
* transitions out of `pending_payment`, so dropping `orders.afterCreate`
|
|
109
|
+
* entirely would silently stop pushing for them.
|
|
110
|
+
*
|
|
111
|
+
* On the transition side the predicate is `fromStatus === PENDING_PAYMENT`, not
|
|
112
|
+
* "the new status looks paid". That is exactly-once by construction: core
|
|
113
|
+
* commits the status with a compare-and-swap, so only one caller wins a given
|
|
114
|
+
* transition. Keying on the new status alone would re-push on every later move
|
|
115
|
+
* (`confirmed -> processing -> fulfilled`), and `exportOrder` short-circuits only
|
|
116
|
+
* on an already-`confirmed` export — one still `exported` is pushed again.
|
|
117
|
+
*/
|
|
118
|
+
function pushHooks(mode) {
|
|
119
|
+
switch (mode) {
|
|
120
|
+
case false:
|
|
121
|
+
return [];
|
|
122
|
+
case "create":
|
|
123
|
+
return [{
|
|
124
|
+
key: "orders.afterCreate",
|
|
125
|
+
async handler(args) {
|
|
126
|
+
const order = parseOrderResult(args);
|
|
127
|
+
if (order === null || !hasHookContext(args))
|
|
128
|
+
return;
|
|
129
|
+
await pushForOrder(order, args.context);
|
|
130
|
+
},
|
|
131
|
+
}];
|
|
132
|
+
case "payment":
|
|
133
|
+
return [{
|
|
134
|
+
key: "orders.afterCreate",
|
|
135
|
+
async handler(args) {
|
|
136
|
+
const order = parseOrderResult(args);
|
|
137
|
+
if (order === null || !hasHookContext(args))
|
|
138
|
+
return;
|
|
139
|
+
// A gateway order waits for its notify; anything else is unchanged.
|
|
140
|
+
if (order.status === PENDING_PAYMENT)
|
|
141
|
+
return;
|
|
142
|
+
await pushForOrder(order, args.context);
|
|
143
|
+
},
|
|
144
|
+
}, {
|
|
145
|
+
key: "orders.afterStatusChange",
|
|
146
|
+
async handler(args) {
|
|
147
|
+
const transition = parseStatusTransition(args);
|
|
148
|
+
const order = parseOrderResult(args);
|
|
149
|
+
if (transition === null || order === null || !hasHookContext(args))
|
|
150
|
+
return;
|
|
151
|
+
if (transition.fromStatus !== PENDING_PAYMENT)
|
|
152
|
+
return;
|
|
153
|
+
if (transition.newStatus === "cancelled")
|
|
154
|
+
return;
|
|
155
|
+
await pushForOrder(order, args.context);
|
|
156
|
+
},
|
|
157
|
+
}];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
6
160
|
export function buildHooks(options) {
|
|
7
161
|
return [{
|
|
8
162
|
key: "checkout.beforePayment",
|
|
@@ -14,16 +168,9 @@ export function buildHooks(options) {
|
|
|
14
168
|
await service.validateLineStock(resolveOrgIdForCommerce(context.actor, context.commerceConfig), data.lineItems, options.inventoryTimeoutMs);
|
|
15
169
|
return data;
|
|
16
170
|
},
|
|
17
|
-
},
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const { result, context } = args;
|
|
21
|
-
const orgId = resolveOrgIdForCommerce(context.actor, context.commerceConfig);
|
|
22
|
-
const entities = await context.db.select({ id: sellableEntities.id, sourceStoreId: sellableEntities.sourceStoreId }).from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.id, (result.lineItems ?? []).map((line) => line.entityId))));
|
|
23
|
-
const stores = new Set(entities.map((entity) => entity.sourceStoreId).filter((storeId) => storeId !== null));
|
|
24
|
-
await Promise.all([...stores].map((storeId) => context.jobs.enqueue("channel/push-order", { orgId, storeId, orderId: result.id }, { organizationId: orgId, concurrencyKey: `push:${result.id}:${storeId}`, supersedes: true })));
|
|
25
|
-
},
|
|
26
|
-
}, {
|
|
171
|
+
},
|
|
172
|
+
...pushHooks(options.pushOrderOn ?? "payment"),
|
|
173
|
+
{
|
|
27
174
|
key: "catalog.beforeUpdate",
|
|
28
175
|
handler(args) {
|
|
29
176
|
const { data, context } = args;
|
package/dist/service.d.ts
CHANGED
|
@@ -100,7 +100,22 @@ export interface ChannelConnectorPluginOptions {
|
|
|
100
100
|
newStoreDays?: number;
|
|
101
101
|
driftAlertThreshold?: number;
|
|
102
102
|
reconcileJitterWindowMs?: number;
|
|
103
|
+
/**
|
|
104
|
+
* When the order push fires. Default `"payment"`.
|
|
105
|
+
*
|
|
106
|
+
* - `"payment"` — an order created in `pending_payment` is NOT pushed; it is
|
|
107
|
+
* pushed when it leaves that state for anything but `cancelled`. An order
|
|
108
|
+
* created in `pending` is pushed on creation, as before, because a store
|
|
109
|
+
* with no payment step has no transition to hang the push on.
|
|
110
|
+
* - `"create"` — the pre-0.40.0 trigger: push as soon as the order row exists,
|
|
111
|
+
* whatever its status.
|
|
112
|
+
* - `false` — never push automatically; the consumer enqueues
|
|
113
|
+
* `channel/push-order` itself.
|
|
114
|
+
*/
|
|
115
|
+
pushOrderOn?: ChannelPushTrigger;
|
|
103
116
|
}
|
|
117
|
+
/** When {@link ChannelConnectorPluginOptions.pushOrderOn} fires the order push. */
|
|
118
|
+
export type ChannelPushTrigger = "create" | "payment" | false;
|
|
104
119
|
export interface ChannelStockLine {
|
|
105
120
|
entityId: string;
|
|
106
121
|
variantId?: string;
|