@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 +21 -0
- package/README.md +97 -0
- package/dist/index.cjs +558 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +268 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +268 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +554 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import { checkRateLimit } from "@thebes/cadmus/rate-limit";
|
|
2
|
+
//#region src/errors.ts
|
|
3
|
+
/**
|
|
4
|
+
* Thrown by `createCheckoutHandler` on price-mismatch/inventory rejection,
|
|
5
|
+
* and by `PaymentProvider` implementations on charge failure.
|
|
6
|
+
* `createCheckoutHandler` catches it internally (`instanceof
|
|
7
|
+
* CadmeaPaymentError`) and maps it to HTTP 402 itself — checkout/webhook
|
|
8
|
+
* handlers are plain Hono routes, never mounted through
|
|
9
|
+
* `@thebes/cadmus/hono`'s `mountCmsRoutes`, so there's no shared
|
|
10
|
+
* error-to-status pipeline this needs to participate in.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately a plain `Error` subclass, not a `CadmusCmsError` one — this
|
|
13
|
+
* is a Cadmea-plugin-owned error, not a Cadmus-primitive one (every real
|
|
14
|
+
* `CadmusError` subclass is owned by a `packages/cadmus/src/<primitive>/`
|
|
15
|
+
* folder; a payment error belongs to a plugin, not a primitive), and
|
|
16
|
+
* `CadmusCmsError` is only reachable via the root `@thebes/cadmus` package
|
|
17
|
+
* export (not `@thebes/cadmus/cms`) — importing that root barrel here
|
|
18
|
+
* would pull in every other primitive's runtime code (including
|
|
19
|
+
* Workers-only modules like `cloudflare:email`) just for one base class.
|
|
20
|
+
* Keeping this plugin-local and dependency-free is the honest shape.
|
|
21
|
+
*/
|
|
22
|
+
var CadmeaPaymentError = class extends Error {
|
|
23
|
+
cause;
|
|
24
|
+
constructor(message, cause) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.cause = cause;
|
|
27
|
+
this.name = "CadmeaPaymentError";
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/checkout.ts
|
|
32
|
+
function subtotalCents(lineItems) {
|
|
33
|
+
return lineItems.reduce((sum, item) => sum + item.clientUnitPrice.amount * item.quantity, 0);
|
|
34
|
+
}
|
|
35
|
+
function generateOrderNumber() {
|
|
36
|
+
return `ORD-${crypto.randomUUID().replace(/-/g, "").slice(0, 12).toUpperCase()}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Returns a Hono handler implementing the checkout flow against a
|
|
40
|
+
* `PaymentProvider`: rate limit → re-verify cart prices/availability
|
|
41
|
+
* (never trust client-submitted prices) → idempotent customer find-or-
|
|
42
|
+
* create → charge → persist `orders`/`payments` rows. A DB-write failure
|
|
43
|
+
* *after* a successful charge degrades to a 200-with-warning response,
|
|
44
|
+
* never a false "payment failed" — the customer's card was actually
|
|
45
|
+
* charged, telling them otherwise would be worse than a delayed manual
|
|
46
|
+
* reconciliation.
|
|
47
|
+
*
|
|
48
|
+
* Mount it as a plain Hono route alongside `mountCmsRoutes` — checkout
|
|
49
|
+
* isn't part of the generic CMS REST surface that function mounts.
|
|
50
|
+
*
|
|
51
|
+
* ```ts
|
|
52
|
+
* app.post("/api/checkout", createCheckoutHandler({ provider, orders, payments, resolveContext }));
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
function createCheckoutHandler(options) {
|
|
56
|
+
return async (c) => {
|
|
57
|
+
if (options.rateLimit) {
|
|
58
|
+
const ip = c.req.header("CF-Connecting-IP") ?? "unknown";
|
|
59
|
+
const key = `${options.rateLimit.keyPrefix ?? "checkout"}:${ip}`;
|
|
60
|
+
if (!(await checkRateLimit(options.rateLimit.kv, key, options.rateLimit.limit, options.rateLimit.windowSeconds)).allowed) return c.json({ error: "Rate limit exceeded" }, 429);
|
|
61
|
+
}
|
|
62
|
+
const body = await c.req.json();
|
|
63
|
+
if (!Array.isArray(body.lineItems) || body.lineItems.length === 0) return c.json({ error: "Checkout request must include at least one line item" }, 400);
|
|
64
|
+
if (!body.idempotencyKey) return c.json({ error: "Checkout request must include an idempotencyKey" }, 400);
|
|
65
|
+
try {
|
|
66
|
+
const refs = body.lineItems.map((item) => item.catalogRef);
|
|
67
|
+
const priceChecks = await options.provider.checkCatalogPrices(refs);
|
|
68
|
+
const checkByRef = new Map(priceChecks.map((check) => [check.catalogRef, check]));
|
|
69
|
+
for (const item of body.lineItems) {
|
|
70
|
+
const check = checkByRef.get(item.catalogRef);
|
|
71
|
+
if (!check) throw new CadmeaPaymentError(`Unknown catalog item "${item.catalogRef}"`);
|
|
72
|
+
if (check.serverUnitPrice.amount !== item.clientUnitPrice.amount) throw new CadmeaPaymentError(`Price mismatch for "${item.catalogRef}" — checkout rejected`);
|
|
73
|
+
if (check.availableQuantity !== void 0 && check.availableQuantity < item.quantity) throw new CadmeaPaymentError(`Insufficient inventory for "${item.catalogRef}"`);
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error instanceof CadmeaPaymentError) return c.json({ error: error.message }, 402);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
if (body.customerEmail) await options.provider.findOrCreateCustomer(body.customerEmail, body.idempotencyKey);
|
|
80
|
+
const result = await options.provider.checkout({
|
|
81
|
+
lineItems: body.lineItems,
|
|
82
|
+
paymentSourceToken: body.paymentSourceToken,
|
|
83
|
+
customerEmail: body.customerEmail,
|
|
84
|
+
idempotencyKey: body.idempotencyKey,
|
|
85
|
+
metadata: body.metadata
|
|
86
|
+
});
|
|
87
|
+
const context = await options.resolveContext(c);
|
|
88
|
+
const orderData = {
|
|
89
|
+
orderNumber: generateOrderNumber(),
|
|
90
|
+
status: result.status === "succeeded" ? "paid" : "pending",
|
|
91
|
+
totalCents: result.amount.amount,
|
|
92
|
+
subtotalCents: subtotalCents(body.lineItems),
|
|
93
|
+
currency: result.amount.currency,
|
|
94
|
+
provider: options.provider.name,
|
|
95
|
+
providerOrderRef: result.providerOrderRef,
|
|
96
|
+
providerPaymentRef: result.providerPaymentRef,
|
|
97
|
+
guestEmail: body.customerEmail,
|
|
98
|
+
lineItems: body.lineItems.map((item) => ({
|
|
99
|
+
productName: item.catalogRef,
|
|
100
|
+
quantity: item.quantity,
|
|
101
|
+
unitPriceCents: item.clientUnitPrice.amount,
|
|
102
|
+
totalPriceCents: item.clientUnitPrice.amount * item.quantity,
|
|
103
|
+
catalogRef: item.catalogRef
|
|
104
|
+
})),
|
|
105
|
+
shippingAddress: body.shippingAddress
|
|
106
|
+
};
|
|
107
|
+
try {
|
|
108
|
+
const order = await options.orders.create(context, orderData);
|
|
109
|
+
await options.payments.create(context, {
|
|
110
|
+
provider: options.provider.name,
|
|
111
|
+
providerPaymentRef: result.providerPaymentRef,
|
|
112
|
+
providerOrderRef: result.providerOrderRef,
|
|
113
|
+
order: order.id,
|
|
114
|
+
status: result.status,
|
|
115
|
+
amountCents: result.amount.amount,
|
|
116
|
+
currency: result.amount.currency,
|
|
117
|
+
rawResponse: result.raw
|
|
118
|
+
});
|
|
119
|
+
return c.json({ order }, 201);
|
|
120
|
+
} catch (cause) {
|
|
121
|
+
return c.json({
|
|
122
|
+
warning: "Payment succeeded but order record-keeping failed — contact support",
|
|
123
|
+
providerPaymentRef: result.providerPaymentRef,
|
|
124
|
+
providerOrderRef: result.providerOrderRef,
|
|
125
|
+
cause: cause instanceof Error ? cause.message : String(cause)
|
|
126
|
+
}, 200);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
//#endregion
|
|
131
|
+
//#region src/collections.ts
|
|
132
|
+
const DEFAULTS = {
|
|
133
|
+
products: "products",
|
|
134
|
+
orders: "orders",
|
|
135
|
+
customers: "customers",
|
|
136
|
+
payments: "payments",
|
|
137
|
+
webhookEvents: "webhook_events",
|
|
138
|
+
subscriptions: "subscriptions",
|
|
139
|
+
users: "users"
|
|
140
|
+
};
|
|
141
|
+
function buildProductsCollection(slug) {
|
|
142
|
+
return {
|
|
143
|
+
slug,
|
|
144
|
+
fields: {
|
|
145
|
+
id: {
|
|
146
|
+
type: "number",
|
|
147
|
+
autoIncrement: true
|
|
148
|
+
},
|
|
149
|
+
name: {
|
|
150
|
+
type: "text",
|
|
151
|
+
required: true
|
|
152
|
+
},
|
|
153
|
+
description: { type: "text" },
|
|
154
|
+
status: {
|
|
155
|
+
type: "select",
|
|
156
|
+
options: [
|
|
157
|
+
"draft",
|
|
158
|
+
"active",
|
|
159
|
+
"archived"
|
|
160
|
+
],
|
|
161
|
+
required: true,
|
|
162
|
+
defaultValue: "draft"
|
|
163
|
+
},
|
|
164
|
+
variants: {
|
|
165
|
+
type: "array",
|
|
166
|
+
required: true,
|
|
167
|
+
fields: {
|
|
168
|
+
sku: {
|
|
169
|
+
type: "text",
|
|
170
|
+
required: true
|
|
171
|
+
},
|
|
172
|
+
catalogRef: {
|
|
173
|
+
type: "text",
|
|
174
|
+
required: true
|
|
175
|
+
},
|
|
176
|
+
priceCents: {
|
|
177
|
+
type: "number",
|
|
178
|
+
required: true
|
|
179
|
+
},
|
|
180
|
+
currency: {
|
|
181
|
+
type: "text",
|
|
182
|
+
defaultValue: "USD"
|
|
183
|
+
},
|
|
184
|
+
inventoryCount: { type: "number" }
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
createdAt: {
|
|
188
|
+
type: "date",
|
|
189
|
+
mode: "timestamp",
|
|
190
|
+
defaultValue: "now"
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function buildOrdersCollection(slug, customersSlug) {
|
|
196
|
+
return {
|
|
197
|
+
slug,
|
|
198
|
+
fields: {
|
|
199
|
+
id: {
|
|
200
|
+
type: "number",
|
|
201
|
+
autoIncrement: true
|
|
202
|
+
},
|
|
203
|
+
orderNumber: {
|
|
204
|
+
type: "text",
|
|
205
|
+
required: true,
|
|
206
|
+
unique: true
|
|
207
|
+
},
|
|
208
|
+
status: {
|
|
209
|
+
type: "select",
|
|
210
|
+
options: [
|
|
211
|
+
"pending",
|
|
212
|
+
"paid",
|
|
213
|
+
"failed",
|
|
214
|
+
"refunded",
|
|
215
|
+
"partially_refunded"
|
|
216
|
+
],
|
|
217
|
+
required: true,
|
|
218
|
+
defaultValue: "pending"
|
|
219
|
+
},
|
|
220
|
+
totalCents: {
|
|
221
|
+
type: "number",
|
|
222
|
+
required: true
|
|
223
|
+
},
|
|
224
|
+
subtotalCents: {
|
|
225
|
+
type: "number",
|
|
226
|
+
required: true
|
|
227
|
+
},
|
|
228
|
+
taxCents: { type: "number" },
|
|
229
|
+
currency: {
|
|
230
|
+
type: "text",
|
|
231
|
+
defaultValue: "USD"
|
|
232
|
+
},
|
|
233
|
+
provider: {
|
|
234
|
+
type: "select",
|
|
235
|
+
options: ["square", "stripe"],
|
|
236
|
+
required: true
|
|
237
|
+
},
|
|
238
|
+
providerOrderRef: { type: "text" },
|
|
239
|
+
providerPaymentRef: { type: "text" },
|
|
240
|
+
customer: {
|
|
241
|
+
type: "relationship",
|
|
242
|
+
relationTo: customersSlug
|
|
243
|
+
},
|
|
244
|
+
guestEmail: { type: "text" },
|
|
245
|
+
lineItems: {
|
|
246
|
+
type: "array",
|
|
247
|
+
required: true,
|
|
248
|
+
fields: {
|
|
249
|
+
productName: {
|
|
250
|
+
type: "text",
|
|
251
|
+
required: true
|
|
252
|
+
},
|
|
253
|
+
quantity: {
|
|
254
|
+
type: "number",
|
|
255
|
+
required: true
|
|
256
|
+
},
|
|
257
|
+
unitPriceCents: {
|
|
258
|
+
type: "number",
|
|
259
|
+
required: true
|
|
260
|
+
},
|
|
261
|
+
totalPriceCents: {
|
|
262
|
+
type: "number",
|
|
263
|
+
required: true
|
|
264
|
+
},
|
|
265
|
+
catalogRef: { type: "text" }
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
shippingAddress: {
|
|
269
|
+
type: "group",
|
|
270
|
+
fields: {
|
|
271
|
+
firstName: { type: "text" },
|
|
272
|
+
lastName: { type: "text" },
|
|
273
|
+
address1: { type: "text" },
|
|
274
|
+
address2: { type: "text" },
|
|
275
|
+
city: { type: "text" },
|
|
276
|
+
state: { type: "text" },
|
|
277
|
+
zip: { type: "text" },
|
|
278
|
+
country: {
|
|
279
|
+
type: "text",
|
|
280
|
+
defaultValue: "US"
|
|
281
|
+
},
|
|
282
|
+
phone: { type: "text" }
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
fulfillmentStatus: {
|
|
286
|
+
type: "select",
|
|
287
|
+
options: [
|
|
288
|
+
"pending",
|
|
289
|
+
"shipped",
|
|
290
|
+
"delivered",
|
|
291
|
+
"failed"
|
|
292
|
+
]
|
|
293
|
+
},
|
|
294
|
+
trackingNumber: { type: "text" },
|
|
295
|
+
trackingCarrier: { type: "text" },
|
|
296
|
+
trackingUrl: { type: "text" },
|
|
297
|
+
createdAt: {
|
|
298
|
+
type: "date",
|
|
299
|
+
mode: "timestamp",
|
|
300
|
+
defaultValue: "now"
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
function buildCustomersCollection(slug, usersSlug) {
|
|
306
|
+
return {
|
|
307
|
+
slug,
|
|
308
|
+
fields: {
|
|
309
|
+
id: {
|
|
310
|
+
type: "number",
|
|
311
|
+
autoIncrement: true
|
|
312
|
+
},
|
|
313
|
+
email: {
|
|
314
|
+
type: "text",
|
|
315
|
+
required: true,
|
|
316
|
+
unique: true
|
|
317
|
+
},
|
|
318
|
+
provider: {
|
|
319
|
+
type: "select",
|
|
320
|
+
options: ["square", "stripe"]
|
|
321
|
+
},
|
|
322
|
+
providerCustomerRef: { type: "text" },
|
|
323
|
+
linkedUser: {
|
|
324
|
+
type: "relationship",
|
|
325
|
+
relationTo: usersSlug
|
|
326
|
+
},
|
|
327
|
+
loyaltyAccountRef: { type: "text" },
|
|
328
|
+
loyaltyPoints: {
|
|
329
|
+
type: "number",
|
|
330
|
+
defaultValue: 0
|
|
331
|
+
},
|
|
332
|
+
createdAt: {
|
|
333
|
+
type: "date",
|
|
334
|
+
mode: "timestamp",
|
|
335
|
+
defaultValue: "now"
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function buildPaymentsCollection(slug, ordersSlug) {
|
|
341
|
+
return {
|
|
342
|
+
slug,
|
|
343
|
+
fields: {
|
|
344
|
+
id: {
|
|
345
|
+
type: "number",
|
|
346
|
+
autoIncrement: true
|
|
347
|
+
},
|
|
348
|
+
provider: {
|
|
349
|
+
type: "select",
|
|
350
|
+
options: ["square", "stripe"],
|
|
351
|
+
required: true
|
|
352
|
+
},
|
|
353
|
+
providerPaymentRef: {
|
|
354
|
+
type: "text",
|
|
355
|
+
required: true
|
|
356
|
+
},
|
|
357
|
+
providerOrderRef: { type: "text" },
|
|
358
|
+
order: {
|
|
359
|
+
type: "relationship",
|
|
360
|
+
relationTo: ordersSlug
|
|
361
|
+
},
|
|
362
|
+
status: { type: "text" },
|
|
363
|
+
amountCents: {
|
|
364
|
+
type: "number",
|
|
365
|
+
required: true
|
|
366
|
+
},
|
|
367
|
+
currency: {
|
|
368
|
+
type: "text",
|
|
369
|
+
defaultValue: "USD"
|
|
370
|
+
},
|
|
371
|
+
rawResponse: { type: "json" },
|
|
372
|
+
createdAt: {
|
|
373
|
+
type: "date",
|
|
374
|
+
mode: "timestamp",
|
|
375
|
+
defaultValue: "now"
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
function buildWebhookEventsCollection(slug) {
|
|
381
|
+
return {
|
|
382
|
+
slug,
|
|
383
|
+
fields: {
|
|
384
|
+
id: {
|
|
385
|
+
type: "number",
|
|
386
|
+
autoIncrement: true
|
|
387
|
+
},
|
|
388
|
+
provider: {
|
|
389
|
+
type: "select",
|
|
390
|
+
options: ["square", "stripe"],
|
|
391
|
+
required: true
|
|
392
|
+
},
|
|
393
|
+
eventId: {
|
|
394
|
+
type: "text",
|
|
395
|
+
required: true,
|
|
396
|
+
unique: true
|
|
397
|
+
},
|
|
398
|
+
eventType: { type: "text" },
|
|
399
|
+
receivedAt: {
|
|
400
|
+
type: "date",
|
|
401
|
+
mode: "timestamp",
|
|
402
|
+
defaultValue: "now"
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
function buildSubscriptionsCollection(slug, customersSlug) {
|
|
408
|
+
return {
|
|
409
|
+
slug,
|
|
410
|
+
fields: {
|
|
411
|
+
id: {
|
|
412
|
+
type: "number",
|
|
413
|
+
autoIncrement: true
|
|
414
|
+
},
|
|
415
|
+
provider: {
|
|
416
|
+
type: "select",
|
|
417
|
+
options: ["square", "stripe"],
|
|
418
|
+
required: true
|
|
419
|
+
},
|
|
420
|
+
providerSubscriptionRef: {
|
|
421
|
+
type: "text",
|
|
422
|
+
required: true
|
|
423
|
+
},
|
|
424
|
+
customer: {
|
|
425
|
+
type: "relationship",
|
|
426
|
+
relationTo: customersSlug
|
|
427
|
+
},
|
|
428
|
+
status: { type: "text" },
|
|
429
|
+
chargedThroughDate: {
|
|
430
|
+
type: "date",
|
|
431
|
+
mode: "timestamp"
|
|
432
|
+
},
|
|
433
|
+
createdAt: {
|
|
434
|
+
type: "date",
|
|
435
|
+
mode: "timestamp",
|
|
436
|
+
defaultValue: "now"
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Returns a Cadmea plugin that adds the provider-agnostic ecommerce
|
|
443
|
+
* collections — a no-op for any collection slug already present, the same
|
|
444
|
+
* idempotent-add convention `cadmea-plugin-redirects`/`cadmea-plugin-crm`
|
|
445
|
+
* use.
|
|
446
|
+
*/
|
|
447
|
+
function ecommercePlugin(options = {}) {
|
|
448
|
+
const slugs = {
|
|
449
|
+
products: options.productsSlug ?? DEFAULTS.products,
|
|
450
|
+
orders: options.ordersSlug ?? DEFAULTS.orders,
|
|
451
|
+
customers: options.customersSlug ?? DEFAULTS.customers,
|
|
452
|
+
payments: options.paymentsSlug ?? DEFAULTS.payments,
|
|
453
|
+
webhookEvents: options.webhookEventsSlug ?? DEFAULTS.webhookEvents,
|
|
454
|
+
subscriptions: options.subscriptionsSlug ?? DEFAULTS.subscriptions,
|
|
455
|
+
users: options.usersSlug ?? DEFAULTS.users
|
|
456
|
+
};
|
|
457
|
+
return (config) => {
|
|
458
|
+
const collections = [...config.collections];
|
|
459
|
+
const addIfMissing = (collection) => {
|
|
460
|
+
if (!collections.some((c) => c.slug === collection.slug)) collections.push(collection);
|
|
461
|
+
};
|
|
462
|
+
addIfMissing(buildProductsCollection(slugs.products));
|
|
463
|
+
addIfMissing(buildOrdersCollection(slugs.orders, slugs.customers));
|
|
464
|
+
addIfMissing(buildCustomersCollection(slugs.customers, slugs.users));
|
|
465
|
+
addIfMissing(buildPaymentsCollection(slugs.payments, slugs.orders));
|
|
466
|
+
addIfMissing(buildWebhookEventsCollection(slugs.webhookEvents));
|
|
467
|
+
if (options.includeSubscriptions) addIfMissing(buildSubscriptionsCollection(slugs.subscriptions, slugs.customers));
|
|
468
|
+
return {
|
|
469
|
+
...config,
|
|
470
|
+
collections
|
|
471
|
+
};
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
//#endregion
|
|
475
|
+
//#region src/webhook.ts
|
|
476
|
+
function isUniqueConstraintError(error) {
|
|
477
|
+
return error instanceof Error && error.message.includes("Unique constraint violated");
|
|
478
|
+
}
|
|
479
|
+
async function findOneByField(api, context, field, value) {
|
|
480
|
+
return (await api.find(context)).find((row) => row[field] === value);
|
|
481
|
+
}
|
|
482
|
+
async function dispatchEvent(event, options) {
|
|
483
|
+
switch (event.kind) {
|
|
484
|
+
case "payment.updated": {
|
|
485
|
+
const payment = await findOneByField(options.payments, options.context, "providerPaymentRef", event.providerPaymentRef);
|
|
486
|
+
if (payment) await options.payments.update(options.context, payment.id, { status: event.status });
|
|
487
|
+
const order = await findOneByField(options.orders, options.context, "providerPaymentRef", event.providerPaymentRef);
|
|
488
|
+
if (order) {
|
|
489
|
+
const status = event.status === "succeeded" ? "paid" : event.status === "refunded" ? "refunded" : "failed";
|
|
490
|
+
await options.orders.update(options.context, order.id, { status });
|
|
491
|
+
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
case "order.updated": {
|
|
495
|
+
const order = await findOneByField(options.orders, options.context, "providerOrderRef", event.providerOrderRef);
|
|
496
|
+
if (order) await options.orders.update(options.context, order.id, { status: event.status });
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
case "subscription.updated": {
|
|
500
|
+
if (!options.subscriptions) return;
|
|
501
|
+
const subscription = await findOneByField(options.subscriptions, options.context, "providerSubscriptionRef", event.providerSubscriptionRef);
|
|
502
|
+
if (subscription) await options.subscriptions.update(options.context, subscription.id, { status: event.status });
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
case "unhandled": return;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Returns a Hono handler implementing inbound webhook handling against a
|
|
510
|
+
* `PaymentProvider`: verify signature (raw body, before any parsing) →
|
|
511
|
+
* dedup via the `webhook_events` collection's unique `eventId` constraint
|
|
512
|
+
* → dispatch by normalized event kind. Each step isolated so a handler bug
|
|
513
|
+
* never prevents the 200 response a provider needs to stop retrying — the
|
|
514
|
+
* dedup write IS the source of truth for "already processed," checked
|
|
515
|
+
* before dispatch, not after.
|
|
516
|
+
*
|
|
517
|
+
* Mount alongside `mountCmsRoutes`, same as `createCheckoutHandler` — not
|
|
518
|
+
* part of the generic CMS REST surface.
|
|
519
|
+
*/
|
|
520
|
+
function createWebhookHandler(options) {
|
|
521
|
+
return async (c) => {
|
|
522
|
+
const rawBody = await c.req.text();
|
|
523
|
+
if (!await options.provider.verifyWebhookSignature({
|
|
524
|
+
rawBody,
|
|
525
|
+
headers: c.req.raw.headers,
|
|
526
|
+
secret: options.secret,
|
|
527
|
+
notificationUrl: options.notificationUrl
|
|
528
|
+
})) return c.json({ error: "Invalid signature" }, 401);
|
|
529
|
+
const { eventId, event } = options.provider.parseWebhookEvent(rawBody);
|
|
530
|
+
try {
|
|
531
|
+
await options.webhookEvents.create(options.context, {
|
|
532
|
+
provider: options.provider.name,
|
|
533
|
+
eventId,
|
|
534
|
+
eventType: event.kind
|
|
535
|
+
});
|
|
536
|
+
} catch (error) {
|
|
537
|
+
if (isUniqueConstraintError(error)) return c.json({
|
|
538
|
+
ok: true,
|
|
539
|
+
duplicate: true
|
|
540
|
+
}, 200);
|
|
541
|
+
throw error;
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
await dispatchEvent(event, options);
|
|
545
|
+
} catch (error) {
|
|
546
|
+
console.error("[cadmea-plugin-ecommerce] webhook dispatch failed", error);
|
|
547
|
+
}
|
|
548
|
+
return c.json({ ok: true }, 200);
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
//#endregion
|
|
552
|
+
export { CadmeaPaymentError, createCheckoutHandler, createWebhookHandler, ecommercePlugin };
|
|
553
|
+
|
|
554
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/errors.ts","../src/checkout.ts","../src/collections.ts","../src/webhook.ts"],"sourcesContent":["// Copyright (c) 2026 BowenLabs. All rights reserved.\n// MIT licensed. See LICENSE in the repo root.\n\n/**\n * Thrown by `createCheckoutHandler` on price-mismatch/inventory rejection,\n * and by `PaymentProvider` implementations on charge failure.\n * `createCheckoutHandler` catches it internally (`instanceof\n * CadmeaPaymentError`) and maps it to HTTP 402 itself — checkout/webhook\n * handlers are plain Hono routes, never mounted through\n * `@thebes/cadmus/hono`'s `mountCmsRoutes`, so there's no shared\n * error-to-status pipeline this needs to participate in.\n *\n * Deliberately a plain `Error` subclass, not a `CadmusCmsError` one — this\n * is a Cadmea-plugin-owned error, not a Cadmus-primitive one (every real\n * `CadmusError` subclass is owned by a `packages/cadmus/src/<primitive>/`\n * folder; a payment error belongs to a plugin, not a primitive), and\n * `CadmusCmsError` is only reachable via the root `@thebes/cadmus` package\n * export (not `@thebes/cadmus/cms`) — importing that root barrel here\n * would pull in every other primitive's runtime code (including\n * Workers-only modules like `cloudflare:email`) just for one base class.\n * Keeping this plugin-local and dependency-free is the honest shape.\n */\nexport class CadmeaPaymentError extends Error {\n constructor(\n message: string,\n public readonly cause?: unknown,\n ) {\n super(message);\n this.name = \"CadmeaPaymentError\";\n }\n}\n","// Copyright (c) 2026 BowenLabs. All rights reserved.\n// MIT licensed. See LICENSE in the repo root.\n\nimport type { LocalApi } from \"@thebes/cadmus/cms\";\nimport { checkRateLimit } from \"@thebes/cadmus/rate-limit\";\nimport type { Context } from \"hono\";\nimport { CadmeaPaymentError } from \"./errors.js\";\nimport type { CartLineItem, PaymentProvider } from \"./types.js\";\n\n// biome-ignore lint/suspicious/noExplicitAny: LocalApi's table generic is erased at this call boundary — same pattern as @thebes/cadmus/hono's CmsRoutesOptions\ntype AnyLocalApi<TContext> = LocalApi<any, TContext>;\n\nexport interface CheckoutRequestBody {\n lineItems: CartLineItem[];\n paymentSourceToken: string;\n customerEmail?: string;\n idempotencyKey: string;\n shippingAddress?: Record<string, string | undefined>;\n metadata?: Record<string, string>;\n}\n\nexport interface CheckoutHandlerOptions<TContext> {\n provider: PaymentProvider;\n orders: AnyLocalApi<TContext>;\n payments: AnyLocalApi<TContext>;\n /**\n * Resolves the per-request access context passed to `orders`/`payments`\n * — called once per request, the same shape and timing as\n * `@thebes/cadmus/hono`'s `mountCmsRoutes`'s own `resolveContext`. This\n * is a real customer-initiated HTTP request (unlike a `CollectionHooks`\n * hook), so a real per-request context is available here, not a fixed\n * trusted value.\n */\n resolveContext: (c: Context) => Promise<TContext> | TContext;\n rateLimit?: {\n kv: KVNamespace;\n limit: number;\n windowSeconds: number;\n /** Default: \"checkout\". */\n keyPrefix?: string;\n };\n}\n\nfunction subtotalCents(lineItems: CartLineItem[]): number {\n return lineItems.reduce(\n (sum, item) => sum + item.clientUnitPrice.amount * item.quantity,\n 0,\n );\n}\n\nfunction generateOrderNumber(): string {\n return `ORD-${crypto.randomUUID().replace(/-/g, \"\").slice(0, 12).toUpperCase()}`;\n}\n\n/**\n * Returns a Hono handler implementing the checkout flow against a\n * `PaymentProvider`: rate limit → re-verify cart prices/availability\n * (never trust client-submitted prices) → idempotent customer find-or-\n * create → charge → persist `orders`/`payments` rows. A DB-write failure\n * *after* a successful charge degrades to a 200-with-warning response,\n * never a false \"payment failed\" — the customer's card was actually\n * charged, telling them otherwise would be worse than a delayed manual\n * reconciliation.\n *\n * Mount it as a plain Hono route alongside `mountCmsRoutes` — checkout\n * isn't part of the generic CMS REST surface that function mounts.\n *\n * ```ts\n * app.post(\"/api/checkout\", createCheckoutHandler({ provider, orders, payments, resolveContext }));\n * ```\n */\nexport function createCheckoutHandler<TContext>(\n options: CheckoutHandlerOptions<TContext>,\n) {\n return async (c: Context): Promise<Response> => {\n if (options.rateLimit) {\n const ip = c.req.header(\"CF-Connecting-IP\") ?? \"unknown\";\n const key = `${options.rateLimit.keyPrefix ?? \"checkout\"}:${ip}`;\n const result = await checkRateLimit(\n options.rateLimit.kv,\n key,\n options.rateLimit.limit,\n options.rateLimit.windowSeconds,\n );\n if (!result.allowed) {\n return c.json({ error: \"Rate limit exceeded\" }, 429);\n }\n }\n\n const body = await c.req.json<CheckoutRequestBody>();\n // Malformed-request checks return 400 directly, not via\n // CadmeaPaymentError — that class is reserved for \"the request was\n // well-formed but the checkout itself can't proceed\" (price/inventory\n // rejections, charge failure below), which this function maps to 402.\n if (!Array.isArray(body.lineItems) || body.lineItems.length === 0) {\n return c.json(\n { error: \"Checkout request must include at least one line item\" },\n 400,\n );\n }\n if (!body.idempotencyKey) {\n return c.json(\n { error: \"Checkout request must include an idempotencyKey\" },\n 400,\n );\n }\n\n try {\n // Re-verify every line item's price/availability against the live\n // catalog — the client-submitted price is never trusted as-is.\n const refs = body.lineItems.map((item) => item.catalogRef);\n const priceChecks = await options.provider.checkCatalogPrices(refs);\n const checkByRef = new Map(\n priceChecks.map((check) => [check.catalogRef, check]),\n );\n for (const item of body.lineItems) {\n const check = checkByRef.get(item.catalogRef);\n if (!check) {\n throw new CadmeaPaymentError(\n `Unknown catalog item \"${item.catalogRef}\"`,\n );\n }\n if (check.serverUnitPrice.amount !== item.clientUnitPrice.amount) {\n throw new CadmeaPaymentError(\n `Price mismatch for \"${item.catalogRef}\" — checkout rejected`,\n );\n }\n if (\n check.availableQuantity !== undefined &&\n check.availableQuantity < item.quantity\n ) {\n throw new CadmeaPaymentError(\n `Insufficient inventory for \"${item.catalogRef}\"`,\n );\n }\n }\n } catch (error) {\n if (error instanceof CadmeaPaymentError) {\n return c.json({ error: error.message }, 402);\n }\n throw error;\n }\n\n if (body.customerEmail) {\n // Idempotent find-or-create — result isn't used directly below\n // (the provider's own checkout() call resolves the customer again\n // internally via paymentSourceToken/customerEmail), but calling it\n // here ensures a customer record exists in the provider before the\n // charge, matching the reference Square plugin's own step ordering.\n await options.provider.findOrCreateCustomer(\n body.customerEmail,\n body.idempotencyKey,\n );\n }\n\n const result = await options.provider.checkout({\n lineItems: body.lineItems,\n paymentSourceToken: body.paymentSourceToken,\n customerEmail: body.customerEmail,\n idempotencyKey: body.idempotencyKey,\n metadata: body.metadata,\n });\n\n const context = await options.resolveContext(c);\n const orderData = {\n orderNumber: generateOrderNumber(),\n status: result.status === \"succeeded\" ? \"paid\" : \"pending\",\n totalCents: result.amount.amount,\n subtotalCents: subtotalCents(body.lineItems),\n currency: result.amount.currency,\n provider: options.provider.name,\n providerOrderRef: result.providerOrderRef,\n providerPaymentRef: result.providerPaymentRef,\n guestEmail: body.customerEmail,\n lineItems: body.lineItems.map((item) => ({\n productName: item.catalogRef,\n quantity: item.quantity,\n unitPriceCents: item.clientUnitPrice.amount,\n totalPriceCents: item.clientUnitPrice.amount * item.quantity,\n catalogRef: item.catalogRef,\n })),\n shippingAddress: body.shippingAddress,\n };\n\n try {\n const order = await options.orders.create(context, orderData);\n await options.payments.create(context, {\n provider: options.provider.name,\n providerPaymentRef: result.providerPaymentRef,\n providerOrderRef: result.providerOrderRef,\n order: order.id,\n status: result.status,\n amountCents: result.amount.amount,\n currency: result.amount.currency,\n rawResponse: result.raw,\n });\n return c.json({ order }, 201);\n } catch (cause) {\n // The charge already succeeded — never report it as failed because\n // our own record-keeping write failed afterwards.\n return c.json(\n {\n warning:\n \"Payment succeeded but order record-keeping failed — contact support\",\n providerPaymentRef: result.providerPaymentRef,\n providerOrderRef: result.providerOrderRef,\n cause: cause instanceof Error ? cause.message : String(cause),\n },\n 200,\n );\n }\n };\n}\n","// Copyright (c) 2026 BowenLabs. All rights reserved.\n// MIT licensed. See LICENSE in the repo root.\n//\n// Provider-agnostic ecommerce collections — Products/Variants, Orders,\n// Customers, Payments (audit log), WebhookEvents (dedup), and an optional\n// Subscriptions collection. Field types are restricted to what\n// @thebes/cadmus/cms actually supports, including the Section 3 `group`\n// (shippingAddress, flattened to real columns) and `json` (rawResponse)\n// additions.\n\nimport type { CadmeaPlugin, CollectionConfig } from \"@thebes/cadmus/cms\";\n\nexport interface EcommercePluginOptions {\n productsSlug?: string;\n ordersSlug?: string;\n customersSlug?: string;\n paymentsSlug?: string;\n webhookEventsSlug?: string;\n subscriptionsSlug?: string;\n /** What `customers.linkedUser` relates to. Default: \"users\". */\n usersSlug?: string;\n /**\n * Adds the `subscriptions` collection. Default: false — Square and\n * Stripe model recurring billing differently enough\n * (`PaymentProvider.subscriptions` is itself optional) that this\n * collection is opt-in, not assumed needed by every store.\n */\n includeSubscriptions?: boolean;\n}\n\nconst DEFAULTS = {\n products: \"products\",\n orders: \"orders\",\n customers: \"customers\",\n payments: \"payments\",\n webhookEvents: \"webhook_events\",\n subscriptions: \"subscriptions\",\n users: \"users\",\n};\n\nfunction buildProductsCollection(slug: string): CollectionConfig {\n return {\n slug,\n fields: {\n id: { type: \"number\", autoIncrement: true },\n name: { type: \"text\", required: true },\n description: { type: \"text\" },\n status: {\n type: \"select\",\n options: [\"draft\", \"active\", \"archived\"],\n required: true,\n defaultValue: \"draft\",\n },\n // No discriminator needed — every variant has the same shape.\n variants: {\n type: \"array\",\n required: true,\n fields: {\n sku: { type: \"text\", required: true },\n catalogRef: { type: \"text\", required: true },\n priceCents: { type: \"number\", required: true },\n currency: { type: \"text\", defaultValue: \"USD\" },\n inventoryCount: { type: \"number\" },\n },\n },\n createdAt: { type: \"date\", mode: \"timestamp\", defaultValue: \"now\" },\n },\n };\n}\n\nfunction buildOrdersCollection(\n slug: string,\n customersSlug: string,\n): CollectionConfig {\n return {\n slug,\n fields: {\n id: { type: \"number\", autoIncrement: true },\n orderNumber: { type: \"text\", required: true, unique: true },\n status: {\n type: \"select\",\n options: [\n \"pending\",\n \"paid\",\n \"failed\",\n \"refunded\",\n \"partially_refunded\",\n ],\n required: true,\n defaultValue: \"pending\",\n },\n totalCents: { type: \"number\", required: true },\n subtotalCents: { type: \"number\", required: true },\n taxCents: { type: \"number\" },\n currency: { type: \"text\", defaultValue: \"USD\" },\n // Which PaymentProvider created this order — needed so webhook\n // dispatch and any provider-specific lookups (tracking, refunds)\n // know which provider's REST API to call back into.\n provider: {\n type: \"select\",\n options: [\"square\", \"stripe\"],\n required: true,\n },\n providerOrderRef: { type: \"text\" },\n providerPaymentRef: { type: \"text\" },\n customer: { type: \"relationship\", relationTo: customersSlug },\n // No native `email` field type — same as the SMB form-builder's own\n // email-field handling, this is a plain `text` column; validate\n // shape in a beforeChange hook if the operator wants that.\n guestEmail: { type: \"text\" },\n lineItems: {\n type: \"array\",\n required: true,\n fields: {\n productName: { type: \"text\", required: true },\n quantity: { type: \"number\", required: true },\n unitPriceCents: { type: \"number\", required: true },\n totalPriceCents: { type: \"number\", required: true },\n catalogRef: { type: \"text\" },\n },\n },\n // The `group` field type (Section 3) — flattens to real prefixed\n // columns (shipping_address_first_name, etc), not a JSON blob, so\n // SQL-level querying on a subfield still works.\n shippingAddress: {\n type: \"group\",\n fields: {\n firstName: { type: \"text\" },\n lastName: { type: \"text\" },\n address1: { type: \"text\" },\n address2: { type: \"text\" },\n city: { type: \"text\" },\n state: { type: \"text\" },\n zip: { type: \"text\" },\n country: { type: \"text\", defaultValue: \"US\" },\n phone: { type: \"text\" },\n },\n },\n fulfillmentStatus: {\n type: \"select\",\n options: [\"pending\", \"shipped\", \"delivered\", \"failed\"],\n },\n trackingNumber: { type: \"text\" },\n trackingCarrier: { type: \"text\" },\n trackingUrl: { type: \"text\" },\n createdAt: { type: \"date\", mode: \"timestamp\", defaultValue: \"now\" },\n },\n };\n}\n\nfunction buildCustomersCollection(\n slug: string,\n usersSlug: string,\n): CollectionConfig {\n return {\n slug,\n fields: {\n id: { type: \"number\", autoIncrement: true },\n email: { type: \"text\", required: true, unique: true },\n provider: { type: \"select\", options: [\"square\", \"stripe\"] },\n providerCustomerRef: { type: \"text\" },\n linkedUser: { type: \"relationship\", relationTo: usersSlug },\n // Square-specific; null for Stripe customers — fine to keep on the\n // shared collection since unused fields are simply null.\n loyaltyAccountRef: { type: \"text\" },\n loyaltyPoints: { type: \"number\", defaultValue: 0 },\n createdAt: { type: \"date\", mode: \"timestamp\", defaultValue: \"now\" },\n },\n };\n}\n\nfunction buildPaymentsCollection(\n slug: string,\n ordersSlug: string,\n): CollectionConfig {\n return {\n slug,\n fields: {\n id: { type: \"number\", autoIncrement: true },\n provider: {\n type: \"select\",\n options: [\"square\", \"stripe\"],\n required: true,\n },\n providerPaymentRef: { type: \"text\", required: true },\n providerOrderRef: { type: \"text\" },\n order: { type: \"relationship\", relationTo: ordersSlug },\n // The provider's own status string, stored as-is for audit fidelity\n // (not normalized) — a `select` would force enumerating every\n // provider's status vocabulary here, exactly the provider-coupling\n // the core/provider split exists to avoid.\n status: { type: \"text\" },\n amountCents: { type: \"number\", required: true },\n currency: { type: \"text\", defaultValue: \"USD\" },\n // The `json` field type (Section 3) — the full raw provider payload,\n // for audit/debugging. The one place a freeform-blob column is\n // genuinely the right shape, not a workaround.\n rawResponse: { type: \"json\" },\n createdAt: { type: \"date\", mode: \"timestamp\", defaultValue: \"now\" },\n },\n };\n}\n\nfunction buildWebhookEventsCollection(slug: string): CollectionConfig {\n return {\n slug,\n fields: {\n id: { type: \"number\", autoIncrement: true },\n provider: {\n type: \"select\",\n options: [\"square\", \"stripe\"],\n required: true,\n },\n // The unique constraint *is* the concurrency-safe dedup guard — a\n // concurrent duplicate naturally throws a unique-constraint\n // CadmusCmsError from create(), which createWebhookHandler treats as\n // \"already processed,\" not a real error.\n eventId: { type: \"text\", required: true, unique: true },\n eventType: { type: \"text\" },\n receivedAt: { type: \"date\", mode: \"timestamp\", defaultValue: \"now\" },\n },\n };\n}\n\nfunction buildSubscriptionsCollection(\n slug: string,\n customersSlug: string,\n): CollectionConfig {\n return {\n slug,\n fields: {\n id: { type: \"number\", autoIncrement: true },\n provider: {\n type: \"select\",\n options: [\"square\", \"stripe\"],\n required: true,\n },\n providerSubscriptionRef: { type: \"text\", required: true },\n customer: { type: \"relationship\", relationTo: customersSlug },\n status: { type: \"text\" },\n chargedThroughDate: { type: \"date\", mode: \"timestamp\" },\n createdAt: { type: \"date\", mode: \"timestamp\", defaultValue: \"now\" },\n },\n };\n}\n\n/**\n * Returns a Cadmea plugin that adds the provider-agnostic ecommerce\n * collections — a no-op for any collection slug already present, the same\n * idempotent-add convention `cadmea-plugin-redirects`/`cadmea-plugin-crm`\n * use.\n */\nexport function ecommercePlugin(\n options: EcommercePluginOptions = {},\n): CadmeaPlugin {\n const slugs = {\n products: options.productsSlug ?? DEFAULTS.products,\n orders: options.ordersSlug ?? DEFAULTS.orders,\n customers: options.customersSlug ?? DEFAULTS.customers,\n payments: options.paymentsSlug ?? DEFAULTS.payments,\n webhookEvents: options.webhookEventsSlug ?? DEFAULTS.webhookEvents,\n subscriptions: options.subscriptionsSlug ?? DEFAULTS.subscriptions,\n users: options.usersSlug ?? DEFAULTS.users,\n };\n\n return (config) => {\n const collections = [...config.collections];\n const addIfMissing = (collection: CollectionConfig) => {\n if (!collections.some((c) => c.slug === collection.slug)) {\n collections.push(collection);\n }\n };\n\n addIfMissing(buildProductsCollection(slugs.products));\n addIfMissing(buildOrdersCollection(slugs.orders, slugs.customers));\n addIfMissing(buildCustomersCollection(slugs.customers, slugs.users));\n addIfMissing(buildPaymentsCollection(slugs.payments, slugs.orders));\n addIfMissing(buildWebhookEventsCollection(slugs.webhookEvents));\n if (options.includeSubscriptions) {\n addIfMissing(\n buildSubscriptionsCollection(slugs.subscriptions, slugs.customers),\n );\n }\n\n return { ...config, collections };\n };\n}\n","// Copyright (c) 2026 BowenLabs. All rights reserved.\n// MIT licensed. See LICENSE in the repo root.\n\nimport type { LocalApi } from \"@thebes/cadmus/cms\";\nimport type { Context } from \"hono\";\nimport type { NormalizedWebhookEvent, PaymentProvider } from \"./types.js\";\n\n// biome-ignore lint/suspicious/noExplicitAny: see checkout.ts's identical note\ntype AnyLocalApi<TContext> = LocalApi<any, TContext>;\n\nexport interface WebhookHandlerOptions<TContext> {\n provider: PaymentProvider;\n webhookEvents: AnyLocalApi<TContext>;\n orders: AnyLocalApi<TContext>;\n payments: AnyLocalApi<TContext>;\n /** Only needed if `provider.subscriptions` is wired and the consumer included the optional `subscriptions` collection. */\n subscriptions?: AnyLocalApi<TContext>;\n secret: string;\n /** Some providers (Square) sign over the full notification URL. */\n notificationUrl?: string;\n /**\n * Webhooks are server-to-server calls with no real user session behind\n * them — same reasoning as `@thebes/cadmea-plugin-crm`'s\n * `createContactUpsertHook`'s `context` option. Pass whatever trusted\n * context value the `orders`/`payments`/`webhookEvents` collections'\n * own `access` config accepts for system-level writes.\n */\n context: TContext;\n}\n\n// Matches the exact message text `localApi.ts`'s `wrapWriteError` authors\n// for a unique-constraint failure — Cadmus-internal, a contract this\n// plugin controls indirectly (same precedent as `@thebes/cadmus/hono`'s\n// `mountCmsRoutes`'s own `statusForError`, which matches the same way\n// rather than importing `CadmusCmsError` from the root `@thebes/cadmus`\n// package — see errors.ts's doc comment for why that root import is\n// avoided here).\nfunction isUniqueConstraintError(error: unknown): boolean {\n return (\n error instanceof Error &&\n error.message.includes(\"Unique constraint violated\")\n );\n}\n\nasync function findOneByField<TContext>(\n api: AnyLocalApi<TContext>,\n context: TContext,\n field: string,\n value: string,\n): Promise<Record<string, unknown> | undefined> {\n // In-memory filter after a plain find() rather than a `where`-filtered\n // query — the same \"don't build for scale you don't have\" tradeoff\n // `cadmea-plugin-redirects`/`cadmea-plugin-crm` make elsewhere. Revisit\n // with an indexed lookup if a high-volume store's orders/payments\n // tables make this a measured problem, not a theoretical one.\n const rows = (await api.find(context)) as Array<Record<string, unknown>>;\n return rows.find((row) => row[field] === value);\n}\n\nasync function dispatchEvent<TContext>(\n event: NormalizedWebhookEvent,\n options: WebhookHandlerOptions<TContext>,\n): Promise<void> {\n switch (event.kind) {\n case \"payment.updated\": {\n const payment = await findOneByField(\n options.payments,\n options.context,\n \"providerPaymentRef\",\n event.providerPaymentRef,\n );\n if (payment) {\n await options.payments.update(options.context, payment.id as number, {\n status: event.status,\n });\n }\n const order = await findOneByField(\n options.orders,\n options.context,\n \"providerPaymentRef\",\n event.providerPaymentRef,\n );\n if (order) {\n const status =\n event.status === \"succeeded\"\n ? \"paid\"\n : event.status === \"refunded\"\n ? \"refunded\"\n : \"failed\";\n await options.orders.update(options.context, order.id as number, {\n status,\n });\n }\n return;\n }\n case \"order.updated\": {\n const order = await findOneByField(\n options.orders,\n options.context,\n \"providerOrderRef\",\n event.providerOrderRef,\n );\n if (order) {\n await options.orders.update(options.context, order.id as number, {\n status: event.status,\n });\n }\n return;\n }\n case \"subscription.updated\": {\n if (!options.subscriptions) return;\n const subscription = await findOneByField(\n options.subscriptions,\n options.context,\n \"providerSubscriptionRef\",\n event.providerSubscriptionRef,\n );\n if (subscription) {\n await options.subscriptions.update(\n options.context,\n subscription.id as number,\n { status: event.status },\n );\n }\n return;\n }\n case \"unhandled\":\n return;\n }\n}\n\n/**\n * Returns a Hono handler implementing inbound webhook handling against a\n * `PaymentProvider`: verify signature (raw body, before any parsing) →\n * dedup via the `webhook_events` collection's unique `eventId` constraint\n * → dispatch by normalized event kind. Each step isolated so a handler bug\n * never prevents the 200 response a provider needs to stop retrying — the\n * dedup write IS the source of truth for \"already processed,\" checked\n * before dispatch, not after.\n *\n * Mount alongside `mountCmsRoutes`, same as `createCheckoutHandler` — not\n * part of the generic CMS REST surface.\n */\nexport function createWebhookHandler<TContext>(\n options: WebhookHandlerOptions<TContext>,\n) {\n return async (c: Context): Promise<Response> => {\n // Read raw body as text *before* any JSON parsing — signature is\n // computed over raw bytes, matching @thebes/cadmus/cms's own\n // outbound webhooks.ts HMAC idiom.\n const rawBody = await c.req.text();\n\n const verified = await options.provider.verifyWebhookSignature({\n rawBody,\n headers: c.req.raw.headers,\n secret: options.secret,\n notificationUrl: options.notificationUrl,\n });\n if (!verified) {\n return c.json({ error: \"Invalid signature\" }, 401);\n }\n\n const { eventId, event } = options.provider.parseWebhookEvent(rawBody);\n\n try {\n await options.webhookEvents.create(options.context, {\n provider: options.provider.name,\n eventId,\n eventType: event.kind,\n });\n } catch (error) {\n if (isUniqueConstraintError(error)) {\n // Already processed — the unique constraint is the actual guard,\n // not a preceding find() (avoids a TOCTOU window on concurrent\n // delivery of the same event).\n return c.json({ ok: true, duplicate: true }, 200);\n }\n throw error;\n }\n\n try {\n await dispatchEvent(event, options);\n } catch (error) {\n // A dispatch-handler bug must not cause the provider to retry the\n // whole event (it's already recorded as processed above) — log and\n // move on, same \"each handler isolated\" precedent the reference\n // Square plugin's own webhook.ts follows.\n console.error(\"[cadmea-plugin-ecommerce] webhook dispatch failed\", error);\n }\n\n return c.json({ ok: true }, 200);\n };\n}\n"],"mappings":"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"}
|