@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/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
|