@porulle/adapter-woocommerce 0.73.2 → 0.74.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.
@@ -0,0 +1,32 @@
1
+ import type { ChannelConnectorError, ChannelEvent, ChannelStore, ChannelWebhookEvent, ChannelWebhookHealth, Result } from "@porulle/core";
2
+ import type { WooClient } from "./client.js";
3
+ /** What every store is subscribed to: catalogue and stock changes, and every change to an order. */
4
+ export declare const WOO_WEBHOOK_TOPICS: readonly ["product.created", "product.updated", "product.deleted", "order.updated"];
5
+ /**
6
+ * A delivery to the store's own address. The ping WooCommerce sends when a subscription is created
7
+ * (form body `webhook_id=N`, unsigned) is `Ok(null)`. Everything else must carry a valid signature.
8
+ *
9
+ * The id is ours: `X-WC-Webhook-Delivery-ID` hashes the subscription id and the current SECOND, so two
10
+ * deliveries in one second collide. The change itself — topic, resource, status, modification time —
11
+ * identifies a delivery and its replays.
12
+ */
13
+ export declare function verifyDelivery(store: ChannelStore, request: Request): Promise<Result<ChannelWebhookEvent | null, ChannelConnectorError>>;
14
+ /**
15
+ * What an order now looks like, as events: cancelled, shipped (wholly or partly), refunded. Read
16
+ * fresh — the delivery only says the order changed. An order the store did not receive from us is
17
+ * none of ours.
18
+ */
19
+ export declare function orderEvents(client: WooClient, remoteOrderId: string): Promise<Result<ChannelEvent[], ChannelConnectorError>>;
20
+ export declare function decodeDelivery(client: WooClient, event: ChannelWebhookEvent, untracked: number): Promise<Result<ChannelEvent[], ChannelConnectorError>>;
21
+ export declare function registerWebhooks(client: WooClient, store: ChannelStore, topics: readonly string[], callbackUrl: string): Promise<Result<{
22
+ registered: number;
23
+ }, ChannelConnectorError>>;
24
+ export declare function unregisterWebhooks(client: WooClient, callbackUrl: string): Promise<Result<{
25
+ removed: number;
26
+ }, ChannelConnectorError>>;
27
+ /**
28
+ * WooCommerce disables a subscription after repeated failed deliveries and never says so. Each expected
29
+ * topic missing, paused or disabled is deleted and created again: re-activating keeps the failure count,
30
+ * so the next failure would disable it again at once.
31
+ */
32
+ export declare function webhookHealth(client: WooClient, store: ChannelStore, topics: readonly string[], callbackUrl: string): Promise<Result<ChannelWebhookHealth, ChannelConnectorError>>;
@@ -0,0 +1,267 @@
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
+ import { Err, Ok } from "@porulle/core";
3
+ import { z } from "zod";
4
+ import { wooProductSchema, wooVariationSchema } from "./catalog.js";
5
+ import { available } from "./inventory.js";
6
+ import { ORDER_META_KEY, wooOrderSchema } from "./orders.js";
7
+ /** What every store is subscribed to: catalogue and stock changes, and every change to an order. */
8
+ export const WOO_WEBHOOK_TOPICS = ["product.created", "product.updated", "product.deleted", "order.updated"];
9
+ const id = z.union([z.number(), z.string()]).transform(String);
10
+ /** Fields of a delivery body that identify the change it reports. */
11
+ const deliveryBody = z.object({
12
+ id: id.optional(),
13
+ status: z.string().nullish(),
14
+ date_modified_gmt: z.string().nullish(),
15
+ });
16
+ function validSignature(secret, body, signature) {
17
+ if (!signature || !secret)
18
+ return false;
19
+ const expected = createHmac("sha256", secret).update(body, "utf8").digest();
20
+ const given = Buffer.from(signature, "base64");
21
+ return given.length === expected.length && timingSafeEqual(given, expected);
22
+ }
23
+ /**
24
+ * A delivery to the store's own address. The ping WooCommerce sends when a subscription is created
25
+ * (form body `webhook_id=N`, unsigned) is `Ok(null)`. Everything else must carry a valid signature.
26
+ *
27
+ * The id is ours: `X-WC-Webhook-Delivery-ID` hashes the subscription id and the current SECOND, so two
28
+ * deliveries in one second collide. The change itself — topic, resource, status, modification time —
29
+ * identifies a delivery and its replays.
30
+ */
31
+ export async function verifyDelivery(store, request) {
32
+ const body = await request.text();
33
+ if (/^webhook_id=\d+$/.test(body.trim()) && !request.headers.get("x-wc-webhook-signature"))
34
+ return Ok(null);
35
+ if (!validSignature(store.webhookSecret ?? "", body, request.headers.get("x-wc-webhook-signature"))) {
36
+ return Err({ code: "INVALID_WEBHOOK_SIGNATURE", message: "Invalid WooCommerce webhook signature.", retriable: false });
37
+ }
38
+ const topic = request.headers.get("x-wc-webhook-topic");
39
+ if (!topic)
40
+ return Err({ code: "INVALID_WEBHOOK", message: "A WooCommerce delivery named no topic.", retriable: false });
41
+ let data;
42
+ try {
43
+ data = JSON.parse(body);
44
+ }
45
+ catch {
46
+ return Err({ code: "INVALID_WEBHOOK", message: "A WooCommerce delivery body was not JSON.", retriable: false });
47
+ }
48
+ const fields = deliveryBody.safeParse(data);
49
+ const change = fields.success ? [topic, fields.data.id ?? "", fields.data.status ?? "", fields.data.date_modified_gmt ?? ""] : [topic, body];
50
+ return Ok({ id: createHash("sha256").update(change.join("|")).digest("hex"), type: topic, data });
51
+ }
52
+ const variationNudge = z.object({ id, type: z.string().optional(), parent_id: id.nullish() });
53
+ /** A product delivery is a nudge: the product (and its stock) is read fresh. */
54
+ async function decodeProduct(client, topic, data, untracked) {
55
+ const nudge = variationNudge.safeParse(data);
56
+ if (!nudge.success)
57
+ return Ok([]);
58
+ const isVariation = nudge.data.type === "variation" && nudge.data.parent_id && nudge.data.parent_id !== "0";
59
+ if (topic === "product.deleted") {
60
+ return Ok(isVariation ? [{ kind: "product.changed", externalIds: [nudge.data.parent_id ?? ""] }] : [{ kind: "product.deleted", externalIds: [nudge.data.id] }]);
61
+ }
62
+ if (isVariation) {
63
+ const variation = await client.get(`/wc/v3/products/${encodeURIComponent(nudge.data.id)}`, wooVariationSchema);
64
+ if (!variation.ok)
65
+ return variation;
66
+ return Ok([
67
+ { kind: "product.changed", externalIds: [nudge.data.parent_id ?? ""] },
68
+ { kind: "inventory.changed", levels: [{ externalId: variation.value.data.id, available: available(variation.value.data, untracked) }] },
69
+ ]);
70
+ }
71
+ const product = await client.get(`/wc/v3/products/${encodeURIComponent(nudge.data.id)}`, wooProductSchema);
72
+ if (!product.ok)
73
+ return product;
74
+ const events = [{ kind: "product.changed", externalIds: [product.value.data.id] }];
75
+ if (product.value.data.type === "simple") {
76
+ events.push({ kind: "inventory.changed", levels: [{ externalId: product.value.data.id, available: available(product.value.data, untracked) }] });
77
+ }
78
+ else if (product.value.data.type === "variable" && product.value.data.variations.length > 0) {
79
+ const variations = await client.all(`/wc/v3/products/${encodeURIComponent(product.value.data.id)}/variations`, wooVariationSchema);
80
+ if (!variations.ok)
81
+ return variations;
82
+ events.push({ kind: "inventory.changed", levels: variations.value.map((row) => ({ externalId: row.id, available: available(row, untracked) })) });
83
+ }
84
+ return Ok(events);
85
+ }
86
+ const fulfillmentSchema = z.object({
87
+ id,
88
+ // A boolean when created, the string "1" or "0" when listed (measured on WooCommerce 11.1.2).
89
+ is_fulfilled: z.union([z.boolean(), z.string(), z.number()]).nullish().transform((value) => value === true || value === "1" || value === 1),
90
+ meta_data: z.array(z.object({ key: z.string(), value: z.unknown() })).default([]),
91
+ });
92
+ const fulfillmentItems = z.array(z.object({ item_id: id, qty: z.coerce.number().int() }));
93
+ const trackingItem = z.object({
94
+ tracking_id: z.string().nullish(),
95
+ tracking_provider: z.string().nullish(),
96
+ custom_tracking_provider: z.string().nullish(),
97
+ tracking_number: z.string().nullish(),
98
+ tracking_link: z.string().nullish(),
99
+ custom_tracking_link: z.string().nullish(),
100
+ });
101
+ const refundSchema = z.object({
102
+ id,
103
+ line_items: z.array(z.object({ product_id: id, variation_id: id, quantity: z.number() })).default([]),
104
+ });
105
+ function meta(entries, key) {
106
+ return entries.find((entry) => entry.key === key)?.value;
107
+ }
108
+ /** The variant id an order line names: its variation's, else its product's. */
109
+ function lineVariant(line) {
110
+ return line.variation_id !== "0" ? line.variation_id : line.product_id;
111
+ }
112
+ /**
113
+ * Where the store keeps tracking, tried in order: WooCommerce's own fulfilments (11.x, when enabled),
114
+ * the Shipment Tracking / Advanced Shipment Tracking route, the same plugins' order meta, and finally
115
+ * none at all (a completed order with no tracking still shipped).
116
+ */
117
+ async function shipments(client, order) {
118
+ const native = await client.get(`/wc/v3/orders/${encodeURIComponent(order.id)}/fulfillments`, z.array(fulfillmentSchema));
119
+ // A store without native fulfilments answers 404 (no such route); any other failure is real.
120
+ if (!native.ok && native.error.status !== 404)
121
+ return native;
122
+ if (native.ok && native.value.data.length > 0) {
123
+ const byItem = new Map(order.line_items.map((line) => [line.id, lineVariant(line)]));
124
+ return Ok(native.value.data.filter((parcel) => parcel.is_fulfilled).map((parcel) => {
125
+ const items = fulfillmentItems.safeParse(meta(parcel.meta_data, "_items"));
126
+ const number = z.string().safeParse(meta(parcel.meta_data, "_tracking_number"));
127
+ const provider = z.string().safeParse(meta(parcel.meta_data, "_shipment_provider"));
128
+ const url = z.string().safeParse(meta(parcel.meta_data, "_tracking_url"));
129
+ return {
130
+ remoteId: `fulfillment-${parcel.id}`,
131
+ ...(provider.success && provider.data ? { carrier: provider.data } : {}),
132
+ ...(number.success && number.data ? { trackingNumber: number.data } : {}),
133
+ ...(url.success && url.data ? { trackingUrl: url.data } : {}),
134
+ lines: items.success ? items.data.flatMap((item) => {
135
+ const variant = byItem.get(item.item_id);
136
+ return variant ? [{ externalVariantId: variant, quantity: item.qty }] : [];
137
+ }) : [],
138
+ source: "core_fulfillments",
139
+ };
140
+ }));
141
+ }
142
+ const tracked = await client.get(`/wc-shipment-tracking/v3/orders/${encodeURIComponent(order.id)}/shipment-trackings`, z.array(trackingItem));
143
+ const fromItems = (items, source) => items.flatMap((item, index) => {
144
+ if (!item.tracking_number)
145
+ return [];
146
+ const carrier = item.custom_tracking_provider || item.tracking_provider;
147
+ const link = item.custom_tracking_link || item.tracking_link;
148
+ return [{ remoteId: `tracking-${item.tracking_id ?? index}`, ...(carrier ? { carrier } : {}), trackingNumber: item.tracking_number, ...(link ? { trackingUrl: link } : {}), lines: [], source }];
149
+ });
150
+ if (tracked.ok && tracked.value.data.length > 0)
151
+ return Ok(fromItems(tracked.value.data, "shipment_tracking"));
152
+ const metaItems = z.array(trackingItem).safeParse(meta(order.meta_data, "_wc_shipment_tracking_items"));
153
+ if (metaItems.success && metaItems.data.length > 0)
154
+ return Ok(fromItems(metaItems.data, "meta"));
155
+ const plainNumber = z.string().min(1).safeParse(meta(order.meta_data, "_tracking_number"));
156
+ if (plainNumber.success)
157
+ return Ok([{ remoteId: `tracking-${plainNumber.data}`, trackingNumber: plainNumber.data, lines: [], source: "meta" }]);
158
+ return Ok([{ remoteId: `completed-${order.id}`, lines: [], source: "status_only" }]);
159
+ }
160
+ /**
161
+ * What an order now looks like, as events: cancelled, shipped (wholly or partly), refunded. Read
162
+ * fresh — the delivery only says the order changed. An order the store did not receive from us is
163
+ * none of ours.
164
+ */
165
+ export async function orderEvents(client, remoteOrderId) {
166
+ const read = await client.get(`/wc/v3/orders/${encodeURIComponent(remoteOrderId)}`, wooOrderSchema);
167
+ if (!read.ok)
168
+ return read;
169
+ const order = read.value.data;
170
+ if (!order.meta_data.some((entry) => entry.key === ORDER_META_KEY))
171
+ return Ok([]);
172
+ const events = [];
173
+ if (order.status === "cancelled")
174
+ events.push({ kind: "order.cancelled", remoteOrderId: order.id });
175
+ const fulfilment = meta(order.meta_data, "_fulfillment_status");
176
+ if (order.status === "completed" || fulfilment === "fulfilled" || fulfilment === "partially_fulfilled") {
177
+ const parcels = await shipments(client, order);
178
+ if (!parcels.ok)
179
+ return parcels;
180
+ events.push({ kind: "order.fulfilled", remoteOrderId: order.id, partial: order.status !== "completed" && fulfilment === "partially_fulfilled", shipments: parcels.value });
181
+ }
182
+ for (const summary of order.refunds) {
183
+ const refund = await client.get(`/wc/v3/orders/${encodeURIComponent(order.id)}/refunds/${encodeURIComponent(summary.id)}`, refundSchema);
184
+ if (!refund.ok)
185
+ return refund;
186
+ events.push({
187
+ kind: "refund.created",
188
+ remoteOrderId: order.id,
189
+ remoteRefundId: refund.value.data.id,
190
+ lines: refund.value.data.line_items.flatMap((line) => {
191
+ const quantity = Math.abs(line.quantity);
192
+ return quantity > 0 ? [{ externalVariantId: line.variation_id !== "0" ? line.variation_id : line.product_id, quantity }] : [];
193
+ }),
194
+ });
195
+ }
196
+ return Ok(events);
197
+ }
198
+ export async function decodeDelivery(client, event, untracked) {
199
+ if (event.type.startsWith("product."))
200
+ return decodeProduct(client, event.type, event.data, untracked);
201
+ if (event.type === "order.updated" || event.type === "order.created") {
202
+ const order = z.object({ id }).safeParse(event.data);
203
+ return order.success ? orderEvents(client, order.data.id) : Ok([]);
204
+ }
205
+ return Ok([]);
206
+ }
207
+ const webhookSchema = z.object({ id, topic: z.string(), status: z.string(), delivery_url: z.string() });
208
+ export async function registerWebhooks(client, store, topics, callbackUrl) {
209
+ if (!store.webhookSecret)
210
+ return Err({ code: "WOO_WEBHOOK_SECRET_MISSING", message: "The store has no webhook secret to sign with.", retriable: false });
211
+ const existing = await client.all("/wc/v3/webhooks", webhookSchema);
212
+ if (!existing.ok)
213
+ return existing;
214
+ let registered = 0;
215
+ for (const topic of topics) {
216
+ // A reconnect keeps a subscription that is already there and active.
217
+ if (existing.value.some((hook) => hook.delivery_url === callbackUrl && hook.topic === topic && hook.status === "active"))
218
+ continue;
219
+ const created = await client.send("POST", "/wc/v3/webhooks", { name: `Runvae ${topic}`, topic, delivery_url: callbackUrl, secret: store.webhookSecret, status: "active" }, webhookSchema);
220
+ if (!created.ok)
221
+ return created;
222
+ registered += 1;
223
+ }
224
+ return Ok({ registered });
225
+ }
226
+ export async function unregisterWebhooks(client, callbackUrl) {
227
+ const existing = await client.all("/wc/v3/webhooks", webhookSchema);
228
+ if (!existing.ok)
229
+ return existing;
230
+ let removed = 0;
231
+ for (const hook of existing.value.filter((candidate) => candidate.delivery_url === callbackUrl)) {
232
+ const deleted = await client.send("DELETE", `/wc/v3/webhooks/${encodeURIComponent(hook.id)}`, undefined, z.unknown(), { force: "true" });
233
+ if (!deleted.ok)
234
+ return deleted;
235
+ removed += 1;
236
+ }
237
+ return Ok({ removed });
238
+ }
239
+ /**
240
+ * WooCommerce disables a subscription after repeated failed deliveries and never says so. Each expected
241
+ * topic missing, paused or disabled is deleted and created again: re-activating keeps the failure count,
242
+ * so the next failure would disable it again at once.
243
+ */
244
+ export async function webhookHealth(client, store, topics, callbackUrl) {
245
+ const existing = await client.all("/wc/v3/webhooks", webhookSchema);
246
+ if (!existing.ok)
247
+ return existing;
248
+ const ours = existing.value.filter((hook) => hook.delivery_url === callbackUrl);
249
+ let repaired = 0;
250
+ const missing = [];
251
+ for (const topic of topics) {
252
+ const matching = ours.filter((hook) => hook.topic === topic);
253
+ if (matching.some((hook) => hook.status === "active"))
254
+ continue;
255
+ for (const hook of matching) {
256
+ const deleted = await client.send("DELETE", `/wc/v3/webhooks/${encodeURIComponent(hook.id)}`, undefined, z.unknown(), { force: "true" });
257
+ if (!deleted.ok)
258
+ return deleted;
259
+ }
260
+ const created = await registerWebhooks(client, store, [topic], callbackUrl);
261
+ if (created.ok)
262
+ repaired += 1;
263
+ else
264
+ missing.push(topic);
265
+ }
266
+ return Ok({ healthy: missing.length === 0, repaired, missing });
267
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/adapter-woocommerce",
3
- "version": "0.73.2",
3
+ "version": "0.74.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -15,15 +15,18 @@
15
15
  "node": ">=20.19.0"
16
16
  },
17
17
  "dependencies": {
18
- "@porulle/core": "0.73.2"
18
+ "zod": "^4.1.11",
19
+ "@porulle/core": "0.74.0"
19
20
  },
20
21
  "devDependencies": {
21
22
  "@types/node": "^24.5.2",
22
23
  "eslint": "^9.39.1",
23
24
  "typescript": "5.9.2",
24
25
  "vitest": "^3.2.4",
25
- "@porulle/typescript-config": "0.1.0",
26
- "@porulle/eslint-config": "0.1.0"
26
+ "esbuild": "0.25.12",
27
+ "miniflare": "4.20260722.1",
28
+ "@porulle/eslint-config": "0.1.0",
29
+ "@porulle/typescript-config": "0.1.0"
27
30
  },
28
31
  "publishConfig": {
29
32
  "access": "public"