@porulle/adapter-woocommerce 0.74.2 → 0.74.4

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.
@@ -7,8 +7,9 @@ export declare const WOO_WEBHOOK_TOPICS: readonly ["product.created", "product.u
7
7
  * (form body `webhook_id=N`, unsigned) is `Ok(null)`. Everything else must carry a valid signature.
8
8
  *
9
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.
10
+ * deliveries in one second collide. So does `date_modified_gmt`, which is also to the second: two stock
11
+ * changes inside one second differ only in the stock. The topic and the whole signed body identify a
12
+ * delivery: a replay is the same bytes, and any other state is a change to apply.
12
13
  */
13
14
  export declare function verifyDelivery(store: ChannelStore, request: Request): Promise<Result<ChannelWebhookEvent | null, ChannelConnectorError>>;
14
15
  /**
package/dist/webhooks.js CHANGED
@@ -7,12 +7,6 @@ import { ORDER_META_KEY, wooOrderSchema } from "./orders.js";
7
7
  /** What every store is subscribed to: catalogue and stock changes, and every change to an order. */
8
8
  export const WOO_WEBHOOK_TOPICS = ["product.created", "product.updated", "product.deleted", "order.updated"];
9
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
10
  function validSignature(secret, body, signature) {
17
11
  if (!signature || !secret)
18
12
  return false;
@@ -25,8 +19,9 @@ function validSignature(secret, body, signature) {
25
19
  * (form body `webhook_id=N`, unsigned) is `Ok(null)`. Everything else must carry a valid signature.
26
20
  *
27
21
  * 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.
22
+ * deliveries in one second collide. So does `date_modified_gmt`, which is also to the second: two stock
23
+ * changes inside one second differ only in the stock. The topic and the whole signed body identify a
24
+ * delivery: a replay is the same bytes, and any other state is a change to apply.
30
25
  */
31
26
  export async function verifyDelivery(store, request) {
32
27
  const body = await request.text();
@@ -45,9 +40,7 @@ export async function verifyDelivery(store, request) {
45
40
  catch {
46
41
  return Err({ code: "INVALID_WEBHOOK", message: "A WooCommerce delivery body was not JSON.", retriable: false });
47
42
  }
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 });
43
+ return Ok({ id: createHash("sha256").update(`${topic}|${body}`).digest("hex"), type: topic, data });
51
44
  }
52
45
  const variationNudge = z.object({ id, type: z.string().optional(), parent_id: id.nullish() });
53
46
  /** A product delivery is a nudge: the product (and its stock) is read fresh. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/adapter-woocommerce",
3
- "version": "0.74.2",
3
+ "version": "0.74.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "zod": "^4.1.11",
19
- "@porulle/core": "0.74.2"
19
+ "@porulle/core": "0.74.4"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/node": "^24.5.2",
package/src/catalog.ts CHANGED
@@ -66,8 +66,51 @@ function status(value: string | null | undefined): ChannelCatalogItem["status"]
66
66
  return undefined;
67
67
  }
68
68
 
69
- function variant(row: WooVariation, currency: string | undefined): ChannelCatalogVariant {
70
- const optionValues = Object.fromEntries(row.attributes.flatMap((attribute) => (attribute.option ? [[attribute.name, attribute.option] as const] : [])));
69
+ /** "pa_color", "Color" and "color" are one attribute; "Blue" and the term slug "blue" one value. */
70
+ function attributeKey(value: string): string {
71
+ return value.trim().toLowerCase().replace(/^pa_/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
72
+ }
73
+
74
+ interface OptionAxis {
75
+ name: string;
76
+ values: string[];
77
+ }
78
+
79
+ /**
80
+ * The option axes a variable product sells along. WooCommerce lets a variation use a global
81
+ * attribute (`pa_color`) by its term SLUG ("blue") while the product lists the term NAME ("Blue") —
82
+ * or does not list that attribute at all (measured on WooCommerce's own sample catalogue). The axes
83
+ * are the product's declared variation attributes plus any its variations use, and each value is
84
+ * spelled as the product spells it where it declares one, so every variant's values are among them.
85
+ */
86
+ function optionAxes(product: WooProduct, variations: WooVariation[]): { axes: OptionAxis[]; resolve: (name: string, option: string) => { name: string; value: string } } {
87
+ const axes = new Map<string, OptionAxis>();
88
+ for (const attribute of product.attributes.filter((candidate) => candidate.variation === true)) {
89
+ axes.set(attributeKey(attribute.name), { name: attribute.name, values: [...attribute.options] });
90
+ }
91
+ const resolve = (name: string, option: string): { name: string; value: string } => {
92
+ const key = attributeKey(name);
93
+ let axis = axes.get(key);
94
+ if (!axis) {
95
+ axis = { name: name.replace(/^pa_/, "").replace(/^./, (first) => first.toUpperCase()), values: [] };
96
+ axes.set(key, axis);
97
+ }
98
+ const declared = axis.values.find((value) => attributeKey(value) === attributeKey(option));
99
+ if (declared) return { name: axis.name, value: declared };
100
+ const shown = option.replace(/^./, (first) => first.toUpperCase());
101
+ axis.values.push(shown);
102
+ return { name: axis.name, value: shown };
103
+ };
104
+ for (const row of variations) for (const attribute of row.attributes) if (attribute.option) resolve(attribute.name, attribute.option);
105
+ return { axes: [...axes.values()], resolve };
106
+ }
107
+
108
+ function variant(row: WooVariation, currency: string | undefined, resolve: (name: string, option: string) => { name: string; value: string }): ChannelCatalogVariant {
109
+ const optionValues = Object.fromEntries(row.attributes.flatMap((attribute) => {
110
+ if (!attribute.option) return [];
111
+ const resolved = resolve(attribute.name, attribute.option);
112
+ return [[resolved.name, resolved.value] as const];
113
+ }));
71
114
  const rowPrices = prices(row, currency);
72
115
  return {
73
116
  externalId: row.id,
@@ -100,15 +143,17 @@ export function catalogItem(product: WooProduct, variations: WooVariation[], cur
100
143
  images.push({ externalId: row.image.id, url: row.image.src, ...(row.image.alt ? { alt: row.image.alt } : {}), role: "gallery", sortOrder: images.length, variantExternalIds: [row.id] });
101
144
  }
102
145
  }
103
- const options = product.attributes.filter((attribute) => attribute.variation === true).map((attribute, index) => ({
104
- name: attribute.name,
105
- displayName: attribute.name,
106
- sortOrder: attribute.position ?? index,
107
- values: attribute.options.map((value, valueIndex) => ({ value, displayValue: value, sortOrder: valueIndex })),
108
- }));
146
+ const sellable = product.type === "simple" ? [] : variations.filter((row) => row.status !== "private");
147
+ const { axes, resolve } = optionAxes(product, sellable);
109
148
  const variants = product.type === "simple"
110
- ? [variant({ id: product.id, sku: product.sku, price: product.price, regular_price: product.regular_price, sale_price: product.sale_price, attributes: [] }, currency)]
111
- : variations.filter((row) => row.status !== "private").map((row) => variant(row, currency));
149
+ ? [variant({ id: product.id, sku: product.sku, price: product.price, regular_price: product.regular_price, sale_price: product.sale_price, attributes: [] }, currency, resolve)]
150
+ : sellable.map((row) => variant(row, currency, resolve));
151
+ const options = product.type === "simple" ? [] : axes.map((axis, index) => ({
152
+ name: axis.name,
153
+ displayName: axis.name,
154
+ sortOrder: index,
155
+ values: axis.values.map((value, valueIndex) => ({ value, displayValue: value, sortOrder: valueIndex })),
156
+ }));
112
157
  const itemStatus = status(product.status);
113
158
  return {
114
159
  externalId: product.id,
package/src/webhooks.ts CHANGED
@@ -13,13 +13,6 @@ export const WOO_WEBHOOK_TOPICS = ["product.created", "product.updated", "produc
13
13
 
14
14
  const id = z.union([z.number(), z.string()]).transform(String);
15
15
 
16
- /** Fields of a delivery body that identify the change it reports. */
17
- const deliveryBody = z.object({
18
- id: id.optional(),
19
- status: z.string().nullish(),
20
- date_modified_gmt: z.string().nullish(),
21
- });
22
-
23
16
  function validSignature(secret: string, body: string, signature: string | null): boolean {
24
17
  if (!signature || !secret) return false;
25
18
  const expected = createHmac("sha256", secret).update(body, "utf8").digest();
@@ -32,8 +25,9 @@ function validSignature(secret: string, body: string, signature: string | null):
32
25
  * (form body `webhook_id=N`, unsigned) is `Ok(null)`. Everything else must carry a valid signature.
33
26
  *
34
27
  * The id is ours: `X-WC-Webhook-Delivery-ID` hashes the subscription id and the current SECOND, so two
35
- * deliveries in one second collide. The change itself — topic, resource, status, modification time —
36
- * identifies a delivery and its replays.
28
+ * deliveries in one second collide. So does `date_modified_gmt`, which is also to the second: two stock
29
+ * changes inside one second differ only in the stock. The topic and the whole signed body identify a
30
+ * delivery: a replay is the same bytes, and any other state is a change to apply.
37
31
  */
38
32
  export async function verifyDelivery(store: ChannelStore, request: Request): Promise<Result<ChannelWebhookEvent | null, ChannelConnectorError>> {
39
33
  const body = await request.text();
@@ -49,9 +43,7 @@ export async function verifyDelivery(store: ChannelStore, request: Request): Pro
49
43
  } catch {
50
44
  return Err({ code: "INVALID_WEBHOOK", message: "A WooCommerce delivery body was not JSON.", retriable: false });
51
45
  }
52
- const fields = deliveryBody.safeParse(data);
53
- const change = fields.success ? [topic, fields.data.id ?? "", fields.data.status ?? "", fields.data.date_modified_gmt ?? ""] : [topic, body];
54
- return Ok({ id: createHash("sha256").update(change.join("|")).digest("hex"), type: topic, data });
46
+ return Ok({ id: createHash("sha256").update(`${topic}|${body}`).digest("hex"), type: topic, data });
55
47
  }
56
48
 
57
49
  const variationNudge = z.object({ id, type: z.string().optional(), parent_id: id.nullish() });