@thebes/cadmea-plugin-ecommerce 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BowenLabs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # @thebes/cadmea-plugin-ecommerce
2
+
3
+ Provider-agnostic ecommerce core for [Cadmea](https://github.com/bowenlabs/project-thebes).
4
+ Ships the `products`/`orders`/`customers`/`payments`/`webhook_events`
5
+ collections (plus an opt-in `subscriptions` collection), the
6
+ `PaymentProvider` interface real provider implementations conform to, and
7
+ `createCheckoutHandler`/`createWebhookHandler` Hono route factories.
8
+
9
+ This is a **Cadmea plugin** — a `plugin(config) => config` transform on the
10
+ `@thebes/cadmus/cms` config. `@thebes/cadmus` and `hono` are peer
11
+ dependencies; nothing here ships at runtime except your own order data.
12
+
13
+ You'll also need a `PaymentProvider` implementation —
14
+ [`@thebes/cadmea-plugin-ecommerce-square`](https://www.npmjs.com/package/@thebes/cadmea-plugin-ecommerce-square)
15
+ and/or
16
+ [`@thebes/cadmea-plugin-ecommerce-stripe`](https://www.npmjs.com/package/@thebes/cadmea-plugin-ecommerce-stripe).
17
+
18
+ ```bash
19
+ pnpm add @thebes/cadmea-plugin-ecommerce
20
+ ```
21
+
22
+ ## Add it to your config
23
+
24
+ ```ts
25
+ import { defineCmsConfig } from "@thebes/cadmus/cms";
26
+ import { ecommercePlugin } from "@thebes/cadmea-plugin-ecommerce";
27
+
28
+ export const cmsConfig = defineCmsConfig({
29
+ collections: [pagesCollection],
30
+ plugins: [ecommercePlugin()],
31
+ });
32
+ ```
33
+
34
+ Pass `{ includeSubscriptions: true }` to add the `subscriptions` collection
35
+ — off by default, since Square and Stripe model recurring billing
36
+ differently enough (`PaymentProvider.subscriptions` is itself optional)
37
+ that it isn't assumed needed by every store. Slugs for every collection are
38
+ configurable (`productsSlug`, `ordersSlug`, etc.) — see the plugin's own
39
+ type signature.
40
+
41
+ ## Mount checkout + webhook routes
42
+
43
+ Neither is part of `mountCmsRoutes`'s generic CMS REST surface — mount them
44
+ as plain Hono routes alongside it:
45
+
46
+ ```ts
47
+ import { createCheckoutHandler, createWebhookHandler } from "@thebes/cadmea-plugin-ecommerce";
48
+ import { createSquarePaymentProvider } from "@thebes/cadmea-plugin-ecommerce-square";
49
+
50
+ const provider = createSquarePaymentProvider({ accessToken, locationId });
51
+
52
+ app.post("/checkout", createCheckoutHandler({
53
+ provider,
54
+ orders: ordersApi,
55
+ payments: paymentsApi,
56
+ resolveContext: async (c) => ({ session: null, internal: true }),
57
+ rateLimit: { kv: c.env.KV, limit: 10, windowSeconds: 60 },
58
+ }));
59
+
60
+ app.post("/webhook", createWebhookHandler({
61
+ provider,
62
+ webhookEvents: webhookEventsApi,
63
+ orders: ordersApi,
64
+ payments: paymentsApi,
65
+ secret: env.SQUARE_WEBHOOK_SECRET,
66
+ notificationUrl: env.SQUARE_WEBHOOK_URL,
67
+ context: { session: null, internal: true },
68
+ }));
69
+ ```
70
+
71
+ `createCheckoutHandler` re-verifies every line item's price/availability
72
+ against the live provider catalog before charging — a client-submitted
73
+ price is never trusted. A DB-write failure *after* a successful charge
74
+ degrades to a `200`-with-warning response, never a false "payment failed."
75
+
76
+ `createWebhookHandler` verifies the raw-body signature before parsing,
77
+ dedups via the `webhook_events` collection's unique `eventId` constraint,
78
+ then dispatches by normalized event kind — each dispatch handler isolated
79
+ so one bug can't prevent the `200` response the provider needs to stop
80
+ retrying.
81
+
82
+ ## `PaymentProvider` — a plugin-defined interface, not a Cadmus adapter
83
+
84
+ `PaymentProvider` mirrors the Cadmus adapter pattern's swappability (one
85
+ interface, N implementations) but is defined by this plugin, not by Cadmus
86
+ core — it needs commerce-domain concepts (cart line items, normalized
87
+ webhook events) that have no business in framework-layer Cadmus. See
88
+ [EXTENDING.md](https://github.com/bowenlabs/project-thebes/blob/main/EXTENDING.md)'s
89
+ "Plugin-defined provider interfaces" section.
90
+
91
+ Both shipped implementations talk to their provider's REST API via raw
92
+ `fetch()` + `crypto.subtle` only — **no Square/Stripe Node SDK, ever** (this
93
+ is a V8-isolate constraint, not a style preference).
94
+
95
+ ## License
96
+
97
+ MIT © BowenLabs
package/dist/index.cjs ADDED
@@ -0,0 +1,558 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _thebes_cadmus_rate_limit = require("@thebes/cadmus/rate-limit");
3
+ //#region src/errors.ts
4
+ /**
5
+ * Thrown by `createCheckoutHandler` on price-mismatch/inventory rejection,
6
+ * and by `PaymentProvider` implementations on charge failure.
7
+ * `createCheckoutHandler` catches it internally (`instanceof
8
+ * CadmeaPaymentError`) and maps it to HTTP 402 itself — checkout/webhook
9
+ * handlers are plain Hono routes, never mounted through
10
+ * `@thebes/cadmus/hono`'s `mountCmsRoutes`, so there's no shared
11
+ * error-to-status pipeline this needs to participate in.
12
+ *
13
+ * Deliberately a plain `Error` subclass, not a `CadmusCmsError` one — this
14
+ * is a Cadmea-plugin-owned error, not a Cadmus-primitive one (every real
15
+ * `CadmusError` subclass is owned by a `packages/cadmus/src/<primitive>/`
16
+ * folder; a payment error belongs to a plugin, not a primitive), and
17
+ * `CadmusCmsError` is only reachable via the root `@thebes/cadmus` package
18
+ * export (not `@thebes/cadmus/cms`) — importing that root barrel here
19
+ * would pull in every other primitive's runtime code (including
20
+ * Workers-only modules like `cloudflare:email`) just for one base class.
21
+ * Keeping this plugin-local and dependency-free is the honest shape.
22
+ */
23
+ var CadmeaPaymentError = class extends Error {
24
+ cause;
25
+ constructor(message, cause) {
26
+ super(message);
27
+ this.cause = cause;
28
+ this.name = "CadmeaPaymentError";
29
+ }
30
+ };
31
+ //#endregion
32
+ //#region src/checkout.ts
33
+ function subtotalCents(lineItems) {
34
+ return lineItems.reduce((sum, item) => sum + item.clientUnitPrice.amount * item.quantity, 0);
35
+ }
36
+ function generateOrderNumber() {
37
+ return `ORD-${crypto.randomUUID().replace(/-/g, "").slice(0, 12).toUpperCase()}`;
38
+ }
39
+ /**
40
+ * Returns a Hono handler implementing the checkout flow against a
41
+ * `PaymentProvider`: rate limit → re-verify cart prices/availability
42
+ * (never trust client-submitted prices) → idempotent customer find-or-
43
+ * create → charge → persist `orders`/`payments` rows. A DB-write failure
44
+ * *after* a successful charge degrades to a 200-with-warning response,
45
+ * never a false "payment failed" — the customer's card was actually
46
+ * charged, telling them otherwise would be worse than a delayed manual
47
+ * reconciliation.
48
+ *
49
+ * Mount it as a plain Hono route alongside `mountCmsRoutes` — checkout
50
+ * isn't part of the generic CMS REST surface that function mounts.
51
+ *
52
+ * ```ts
53
+ * app.post("/api/checkout", createCheckoutHandler({ provider, orders, payments, resolveContext }));
54
+ * ```
55
+ */
56
+ function createCheckoutHandler(options) {
57
+ return async (c) => {
58
+ if (options.rateLimit) {
59
+ const ip = c.req.header("CF-Connecting-IP") ?? "unknown";
60
+ const key = `${options.rateLimit.keyPrefix ?? "checkout"}:${ip}`;
61
+ if (!(await (0, _thebes_cadmus_rate_limit.checkRateLimit)(options.rateLimit.kv, key, options.rateLimit.limit, options.rateLimit.windowSeconds)).allowed) return c.json({ error: "Rate limit exceeded" }, 429);
62
+ }
63
+ const body = await c.req.json();
64
+ if (!Array.isArray(body.lineItems) || body.lineItems.length === 0) return c.json({ error: "Checkout request must include at least one line item" }, 400);
65
+ if (!body.idempotencyKey) return c.json({ error: "Checkout request must include an idempotencyKey" }, 400);
66
+ try {
67
+ const refs = body.lineItems.map((item) => item.catalogRef);
68
+ const priceChecks = await options.provider.checkCatalogPrices(refs);
69
+ const checkByRef = new Map(priceChecks.map((check) => [check.catalogRef, check]));
70
+ for (const item of body.lineItems) {
71
+ const check = checkByRef.get(item.catalogRef);
72
+ if (!check) throw new CadmeaPaymentError(`Unknown catalog item "${item.catalogRef}"`);
73
+ if (check.serverUnitPrice.amount !== item.clientUnitPrice.amount) throw new CadmeaPaymentError(`Price mismatch for "${item.catalogRef}" — checkout rejected`);
74
+ if (check.availableQuantity !== void 0 && check.availableQuantity < item.quantity) throw new CadmeaPaymentError(`Insufficient inventory for "${item.catalogRef}"`);
75
+ }
76
+ } catch (error) {
77
+ if (error instanceof CadmeaPaymentError) return c.json({ error: error.message }, 402);
78
+ throw error;
79
+ }
80
+ if (body.customerEmail) await options.provider.findOrCreateCustomer(body.customerEmail, body.idempotencyKey);
81
+ const result = await options.provider.checkout({
82
+ lineItems: body.lineItems,
83
+ paymentSourceToken: body.paymentSourceToken,
84
+ customerEmail: body.customerEmail,
85
+ idempotencyKey: body.idempotencyKey,
86
+ metadata: body.metadata
87
+ });
88
+ const context = await options.resolveContext(c);
89
+ const orderData = {
90
+ orderNumber: generateOrderNumber(),
91
+ status: result.status === "succeeded" ? "paid" : "pending",
92
+ totalCents: result.amount.amount,
93
+ subtotalCents: subtotalCents(body.lineItems),
94
+ currency: result.amount.currency,
95
+ provider: options.provider.name,
96
+ providerOrderRef: result.providerOrderRef,
97
+ providerPaymentRef: result.providerPaymentRef,
98
+ guestEmail: body.customerEmail,
99
+ lineItems: body.lineItems.map((item) => ({
100
+ productName: item.catalogRef,
101
+ quantity: item.quantity,
102
+ unitPriceCents: item.clientUnitPrice.amount,
103
+ totalPriceCents: item.clientUnitPrice.amount * item.quantity,
104
+ catalogRef: item.catalogRef
105
+ })),
106
+ shippingAddress: body.shippingAddress
107
+ };
108
+ try {
109
+ const order = await options.orders.create(context, orderData);
110
+ await options.payments.create(context, {
111
+ provider: options.provider.name,
112
+ providerPaymentRef: result.providerPaymentRef,
113
+ providerOrderRef: result.providerOrderRef,
114
+ order: order.id,
115
+ status: result.status,
116
+ amountCents: result.amount.amount,
117
+ currency: result.amount.currency,
118
+ rawResponse: result.raw
119
+ });
120
+ return c.json({ order }, 201);
121
+ } catch (cause) {
122
+ return c.json({
123
+ warning: "Payment succeeded but order record-keeping failed — contact support",
124
+ providerPaymentRef: result.providerPaymentRef,
125
+ providerOrderRef: result.providerOrderRef,
126
+ cause: cause instanceof Error ? cause.message : String(cause)
127
+ }, 200);
128
+ }
129
+ };
130
+ }
131
+ //#endregion
132
+ //#region src/collections.ts
133
+ const DEFAULTS = {
134
+ products: "products",
135
+ orders: "orders",
136
+ customers: "customers",
137
+ payments: "payments",
138
+ webhookEvents: "webhook_events",
139
+ subscriptions: "subscriptions",
140
+ users: "users"
141
+ };
142
+ function buildProductsCollection(slug) {
143
+ return {
144
+ slug,
145
+ fields: {
146
+ id: {
147
+ type: "number",
148
+ autoIncrement: true
149
+ },
150
+ name: {
151
+ type: "text",
152
+ required: true
153
+ },
154
+ description: { type: "text" },
155
+ status: {
156
+ type: "select",
157
+ options: [
158
+ "draft",
159
+ "active",
160
+ "archived"
161
+ ],
162
+ required: true,
163
+ defaultValue: "draft"
164
+ },
165
+ variants: {
166
+ type: "array",
167
+ required: true,
168
+ fields: {
169
+ sku: {
170
+ type: "text",
171
+ required: true
172
+ },
173
+ catalogRef: {
174
+ type: "text",
175
+ required: true
176
+ },
177
+ priceCents: {
178
+ type: "number",
179
+ required: true
180
+ },
181
+ currency: {
182
+ type: "text",
183
+ defaultValue: "USD"
184
+ },
185
+ inventoryCount: { type: "number" }
186
+ }
187
+ },
188
+ createdAt: {
189
+ type: "date",
190
+ mode: "timestamp",
191
+ defaultValue: "now"
192
+ }
193
+ }
194
+ };
195
+ }
196
+ function buildOrdersCollection(slug, customersSlug) {
197
+ return {
198
+ slug,
199
+ fields: {
200
+ id: {
201
+ type: "number",
202
+ autoIncrement: true
203
+ },
204
+ orderNumber: {
205
+ type: "text",
206
+ required: true,
207
+ unique: true
208
+ },
209
+ status: {
210
+ type: "select",
211
+ options: [
212
+ "pending",
213
+ "paid",
214
+ "failed",
215
+ "refunded",
216
+ "partially_refunded"
217
+ ],
218
+ required: true,
219
+ defaultValue: "pending"
220
+ },
221
+ totalCents: {
222
+ type: "number",
223
+ required: true
224
+ },
225
+ subtotalCents: {
226
+ type: "number",
227
+ required: true
228
+ },
229
+ taxCents: { type: "number" },
230
+ currency: {
231
+ type: "text",
232
+ defaultValue: "USD"
233
+ },
234
+ provider: {
235
+ type: "select",
236
+ options: ["square", "stripe"],
237
+ required: true
238
+ },
239
+ providerOrderRef: { type: "text" },
240
+ providerPaymentRef: { type: "text" },
241
+ customer: {
242
+ type: "relationship",
243
+ relationTo: customersSlug
244
+ },
245
+ guestEmail: { type: "text" },
246
+ lineItems: {
247
+ type: "array",
248
+ required: true,
249
+ fields: {
250
+ productName: {
251
+ type: "text",
252
+ required: true
253
+ },
254
+ quantity: {
255
+ type: "number",
256
+ required: true
257
+ },
258
+ unitPriceCents: {
259
+ type: "number",
260
+ required: true
261
+ },
262
+ totalPriceCents: {
263
+ type: "number",
264
+ required: true
265
+ },
266
+ catalogRef: { type: "text" }
267
+ }
268
+ },
269
+ shippingAddress: {
270
+ type: "group",
271
+ fields: {
272
+ firstName: { type: "text" },
273
+ lastName: { type: "text" },
274
+ address1: { type: "text" },
275
+ address2: { type: "text" },
276
+ city: { type: "text" },
277
+ state: { type: "text" },
278
+ zip: { type: "text" },
279
+ country: {
280
+ type: "text",
281
+ defaultValue: "US"
282
+ },
283
+ phone: { type: "text" }
284
+ }
285
+ },
286
+ fulfillmentStatus: {
287
+ type: "select",
288
+ options: [
289
+ "pending",
290
+ "shipped",
291
+ "delivered",
292
+ "failed"
293
+ ]
294
+ },
295
+ trackingNumber: { type: "text" },
296
+ trackingCarrier: { type: "text" },
297
+ trackingUrl: { type: "text" },
298
+ createdAt: {
299
+ type: "date",
300
+ mode: "timestamp",
301
+ defaultValue: "now"
302
+ }
303
+ }
304
+ };
305
+ }
306
+ function buildCustomersCollection(slug, usersSlug) {
307
+ return {
308
+ slug,
309
+ fields: {
310
+ id: {
311
+ type: "number",
312
+ autoIncrement: true
313
+ },
314
+ email: {
315
+ type: "text",
316
+ required: true,
317
+ unique: true
318
+ },
319
+ provider: {
320
+ type: "select",
321
+ options: ["square", "stripe"]
322
+ },
323
+ providerCustomerRef: { type: "text" },
324
+ linkedUser: {
325
+ type: "relationship",
326
+ relationTo: usersSlug
327
+ },
328
+ loyaltyAccountRef: { type: "text" },
329
+ loyaltyPoints: {
330
+ type: "number",
331
+ defaultValue: 0
332
+ },
333
+ createdAt: {
334
+ type: "date",
335
+ mode: "timestamp",
336
+ defaultValue: "now"
337
+ }
338
+ }
339
+ };
340
+ }
341
+ function buildPaymentsCollection(slug, ordersSlug) {
342
+ return {
343
+ slug,
344
+ fields: {
345
+ id: {
346
+ type: "number",
347
+ autoIncrement: true
348
+ },
349
+ provider: {
350
+ type: "select",
351
+ options: ["square", "stripe"],
352
+ required: true
353
+ },
354
+ providerPaymentRef: {
355
+ type: "text",
356
+ required: true
357
+ },
358
+ providerOrderRef: { type: "text" },
359
+ order: {
360
+ type: "relationship",
361
+ relationTo: ordersSlug
362
+ },
363
+ status: { type: "text" },
364
+ amountCents: {
365
+ type: "number",
366
+ required: true
367
+ },
368
+ currency: {
369
+ type: "text",
370
+ defaultValue: "USD"
371
+ },
372
+ rawResponse: { type: "json" },
373
+ createdAt: {
374
+ type: "date",
375
+ mode: "timestamp",
376
+ defaultValue: "now"
377
+ }
378
+ }
379
+ };
380
+ }
381
+ function buildWebhookEventsCollection(slug) {
382
+ return {
383
+ slug,
384
+ fields: {
385
+ id: {
386
+ type: "number",
387
+ autoIncrement: true
388
+ },
389
+ provider: {
390
+ type: "select",
391
+ options: ["square", "stripe"],
392
+ required: true
393
+ },
394
+ eventId: {
395
+ type: "text",
396
+ required: true,
397
+ unique: true
398
+ },
399
+ eventType: { type: "text" },
400
+ receivedAt: {
401
+ type: "date",
402
+ mode: "timestamp",
403
+ defaultValue: "now"
404
+ }
405
+ }
406
+ };
407
+ }
408
+ function buildSubscriptionsCollection(slug, customersSlug) {
409
+ return {
410
+ slug,
411
+ fields: {
412
+ id: {
413
+ type: "number",
414
+ autoIncrement: true
415
+ },
416
+ provider: {
417
+ type: "select",
418
+ options: ["square", "stripe"],
419
+ required: true
420
+ },
421
+ providerSubscriptionRef: {
422
+ type: "text",
423
+ required: true
424
+ },
425
+ customer: {
426
+ type: "relationship",
427
+ relationTo: customersSlug
428
+ },
429
+ status: { type: "text" },
430
+ chargedThroughDate: {
431
+ type: "date",
432
+ mode: "timestamp"
433
+ },
434
+ createdAt: {
435
+ type: "date",
436
+ mode: "timestamp",
437
+ defaultValue: "now"
438
+ }
439
+ }
440
+ };
441
+ }
442
+ /**
443
+ * Returns a Cadmea plugin that adds the provider-agnostic ecommerce
444
+ * collections — a no-op for any collection slug already present, the same
445
+ * idempotent-add convention `cadmea-plugin-redirects`/`cadmea-plugin-crm`
446
+ * use.
447
+ */
448
+ function ecommercePlugin(options = {}) {
449
+ const slugs = {
450
+ products: options.productsSlug ?? DEFAULTS.products,
451
+ orders: options.ordersSlug ?? DEFAULTS.orders,
452
+ customers: options.customersSlug ?? DEFAULTS.customers,
453
+ payments: options.paymentsSlug ?? DEFAULTS.payments,
454
+ webhookEvents: options.webhookEventsSlug ?? DEFAULTS.webhookEvents,
455
+ subscriptions: options.subscriptionsSlug ?? DEFAULTS.subscriptions,
456
+ users: options.usersSlug ?? DEFAULTS.users
457
+ };
458
+ return (config) => {
459
+ const collections = [...config.collections];
460
+ const addIfMissing = (collection) => {
461
+ if (!collections.some((c) => c.slug === collection.slug)) collections.push(collection);
462
+ };
463
+ addIfMissing(buildProductsCollection(slugs.products));
464
+ addIfMissing(buildOrdersCollection(slugs.orders, slugs.customers));
465
+ addIfMissing(buildCustomersCollection(slugs.customers, slugs.users));
466
+ addIfMissing(buildPaymentsCollection(slugs.payments, slugs.orders));
467
+ addIfMissing(buildWebhookEventsCollection(slugs.webhookEvents));
468
+ if (options.includeSubscriptions) addIfMissing(buildSubscriptionsCollection(slugs.subscriptions, slugs.customers));
469
+ return {
470
+ ...config,
471
+ collections
472
+ };
473
+ };
474
+ }
475
+ //#endregion
476
+ //#region src/webhook.ts
477
+ function isUniqueConstraintError(error) {
478
+ return error instanceof Error && error.message.includes("Unique constraint violated");
479
+ }
480
+ async function findOneByField(api, context, field, value) {
481
+ return (await api.find(context)).find((row) => row[field] === value);
482
+ }
483
+ async function dispatchEvent(event, options) {
484
+ switch (event.kind) {
485
+ case "payment.updated": {
486
+ const payment = await findOneByField(options.payments, options.context, "providerPaymentRef", event.providerPaymentRef);
487
+ if (payment) await options.payments.update(options.context, payment.id, { status: event.status });
488
+ const order = await findOneByField(options.orders, options.context, "providerPaymentRef", event.providerPaymentRef);
489
+ if (order) {
490
+ const status = event.status === "succeeded" ? "paid" : event.status === "refunded" ? "refunded" : "failed";
491
+ await options.orders.update(options.context, order.id, { status });
492
+ }
493
+ return;
494
+ }
495
+ case "order.updated": {
496
+ const order = await findOneByField(options.orders, options.context, "providerOrderRef", event.providerOrderRef);
497
+ if (order) await options.orders.update(options.context, order.id, { status: event.status });
498
+ return;
499
+ }
500
+ case "subscription.updated": {
501
+ if (!options.subscriptions) return;
502
+ const subscription = await findOneByField(options.subscriptions, options.context, "providerSubscriptionRef", event.providerSubscriptionRef);
503
+ if (subscription) await options.subscriptions.update(options.context, subscription.id, { status: event.status });
504
+ return;
505
+ }
506
+ case "unhandled": return;
507
+ }
508
+ }
509
+ /**
510
+ * Returns a Hono handler implementing inbound webhook handling against a
511
+ * `PaymentProvider`: verify signature (raw body, before any parsing) →
512
+ * dedup via the `webhook_events` collection's unique `eventId` constraint
513
+ * → dispatch by normalized event kind. Each step isolated so a handler bug
514
+ * never prevents the 200 response a provider needs to stop retrying — the
515
+ * dedup write IS the source of truth for "already processed," checked
516
+ * before dispatch, not after.
517
+ *
518
+ * Mount alongside `mountCmsRoutes`, same as `createCheckoutHandler` — not
519
+ * part of the generic CMS REST surface.
520
+ */
521
+ function createWebhookHandler(options) {
522
+ return async (c) => {
523
+ const rawBody = await c.req.text();
524
+ if (!await options.provider.verifyWebhookSignature({
525
+ rawBody,
526
+ headers: c.req.raw.headers,
527
+ secret: options.secret,
528
+ notificationUrl: options.notificationUrl
529
+ })) return c.json({ error: "Invalid signature" }, 401);
530
+ const { eventId, event } = options.provider.parseWebhookEvent(rawBody);
531
+ try {
532
+ await options.webhookEvents.create(options.context, {
533
+ provider: options.provider.name,
534
+ eventId,
535
+ eventType: event.kind
536
+ });
537
+ } catch (error) {
538
+ if (isUniqueConstraintError(error)) return c.json({
539
+ ok: true,
540
+ duplicate: true
541
+ }, 200);
542
+ throw error;
543
+ }
544
+ try {
545
+ await dispatchEvent(event, options);
546
+ } catch (error) {
547
+ console.error("[cadmea-plugin-ecommerce] webhook dispatch failed", error);
548
+ }
549
+ return c.json({ ok: true }, 200);
550
+ };
551
+ }
552
+ //#endregion
553
+ exports.CadmeaPaymentError = CadmeaPaymentError;
554
+ exports.createCheckoutHandler = createCheckoutHandler;
555
+ exports.createWebhookHandler = createWebhookHandler;
556
+ exports.ecommercePlugin = ecommercePlugin;
557
+
558
+ //# sourceMappingURL=index.cjs.map