@porulle/plugin-channel-connector 0.39.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 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
- key: "orders.afterCreate",
19
- async handler(args) {
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;