@thebes/cadmea-plugin-ecommerce 1.0.0 → 1.1.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/dist/index.cjs CHANGED
@@ -292,6 +292,8 @@ function buildOrdersCollection(slug, customersSlug) {
292
292
  "failed"
293
293
  ]
294
294
  },
295
+ fulfillmentProvider: { type: "text" },
296
+ fulfillmentProviderRef: { type: "text" },
295
297
  trackingNumber: { type: "text" },
296
298
  trackingCarrier: { type: "text" },
297
299
  trackingUrl: { type: "text" },
@@ -473,6 +475,104 @@ function ecommercePlugin(options = {}) {
473
475
  };
474
476
  }
475
477
  //#endregion
478
+ //#region src/fulfillment.ts
479
+ /**
480
+ * The order-paid hook implementation: submits an already-paid order's line
481
+ * items to a `FulfillmentProvider` and persists the resulting
482
+ * `fulfillmentProvider`/`fulfillmentProviderRef`/`fulfillmentStatus` back
483
+ * onto the order row. Wire it as `WebhookHandlerOptions.onOrderPaid` on the
484
+ * *payment* provider's webhook handler:
485
+ *
486
+ * ```ts
487
+ * createWebhookHandler({
488
+ * provider: stripeProvider,
489
+ * orders, payments, webhookEvents, secret, context,
490
+ * onOrderPaid: (order) =>
491
+ * createFulfillmentOrder(order, { provider: printfulProvider, orders, context }),
492
+ * });
493
+ * ```
494
+ *
495
+ * Digital-goods-only stores simply never wire this — `fulfillmentProvider`
496
+ * is plugin-optional, not a hard dependency of `ecommercePlugin`.
497
+ */
498
+ async function createFulfillmentOrder(order, options) {
499
+ const lineItems = order.lineItems ?? [];
500
+ const shippingAddress = order.shippingAddress ?? {};
501
+ const result = await options.provider.createFulfillmentOrder({
502
+ orderId: order.id,
503
+ lineItems: lineItems.filter((item) => Boolean(item.catalogRef)).map((item) => ({
504
+ catalogRef: item.catalogRef,
505
+ quantity: item.quantity
506
+ })),
507
+ shippingAddress,
508
+ customerEmail: order.guestEmail
509
+ });
510
+ await options.orders.update(options.context, order.id, {
511
+ fulfillmentProvider: options.provider.name,
512
+ fulfillmentProviderRef: result.providerFulfillmentRef,
513
+ fulfillmentStatus: result.status
514
+ });
515
+ }
516
+ async function findOrderByFulfillmentRef(orders, context, providerFulfillmentRef) {
517
+ return (await orders.find(context)).find((row) => row.fulfillmentProviderRef === providerFulfillmentRef);
518
+ }
519
+ function isUniqueConstraintError$1(error) {
520
+ return error instanceof Error && error.message.includes("Unique constraint violated");
521
+ }
522
+ async function dispatchFulfillmentEvent(event, options) {
523
+ if (event.kind === "unhandled") return;
524
+ const order = await findOrderByFulfillmentRef(options.orders, options.context, event.providerFulfillmentRef);
525
+ if (!order) return;
526
+ await options.orders.update(options.context, order.id, {
527
+ fulfillmentStatus: event.status,
528
+ trackingNumber: event.trackingNumber,
529
+ trackingCarrier: event.trackingCarrier,
530
+ trackingUrl: event.trackingUrl
531
+ });
532
+ }
533
+ /**
534
+ * Returns a Hono handler implementing inbound fulfillment-webhook handling
535
+ * (shipment created/delivered/failed) against a `FulfillmentProvider` —
536
+ * verify signature → dedup via `webhook_events` (shared with the payment
537
+ * webhook handler; `eventId` is provider-specific so cross-provider
538
+ * collisions aren't a concern) → look up the order by
539
+ * `fulfillmentProviderRef` → update shipment status/tracking. Mirrors
540
+ * `createWebhookHandler`'s structure exactly; kept as a separate function
541
+ * rather than a generic merge of the two because the payment and
542
+ * fulfillment event vocabularies, dedup keys, and target collections don't
543
+ * overlap enough to share logic without branching on provider kind.
544
+ */
545
+ function createFulfillmentWebhookHandler(options) {
546
+ return async (c) => {
547
+ const rawBody = await c.req.text();
548
+ if (!await options.provider.verifyWebhookSignature({
549
+ rawBody,
550
+ headers: c.req.raw.headers,
551
+ secret: options.secret
552
+ })) return c.json({ error: "Invalid signature" }, 401);
553
+ const { eventId, event } = options.provider.parseWebhookEvent(rawBody);
554
+ try {
555
+ await options.webhookEvents.create(options.context, {
556
+ provider: options.provider.name,
557
+ eventId,
558
+ eventType: event.kind
559
+ });
560
+ } catch (error) {
561
+ if (isUniqueConstraintError$1(error)) return c.json({
562
+ ok: true,
563
+ duplicate: true
564
+ }, 200);
565
+ throw error;
566
+ }
567
+ try {
568
+ await dispatchFulfillmentEvent(event, options);
569
+ } catch (error) {
570
+ console.error("[cadmea-plugin-ecommerce] fulfillment webhook dispatch failed", error);
571
+ }
572
+ return c.json({ ok: true }, 200);
573
+ };
574
+ }
575
+ //#endregion
476
576
  //#region src/webhook.ts
477
577
  function isUniqueConstraintError(error) {
478
578
  return error instanceof Error && error.message.includes("Unique constraint violated");
@@ -488,13 +588,17 @@ async function dispatchEvent(event, options) {
488
588
  const order = await findOneByField(options.orders, options.context, "providerPaymentRef", event.providerPaymentRef);
489
589
  if (order) {
490
590
  const status = event.status === "succeeded" ? "paid" : event.status === "refunded" ? "refunded" : "failed";
491
- await options.orders.update(options.context, order.id, { status });
591
+ const updated = await options.orders.update(options.context, order.id, { status });
592
+ if (status === "paid" && options.onOrderPaid) await options.onOrderPaid(updated);
492
593
  }
493
594
  return;
494
595
  }
495
596
  case "order.updated": {
496
597
  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 });
598
+ if (order) {
599
+ const updated = await options.orders.update(options.context, order.id, { status: event.status });
600
+ if (event.status === "paid" && options.onOrderPaid) await options.onOrderPaid(updated);
601
+ }
498
602
  return;
499
603
  }
500
604
  case "subscription.updated": {
@@ -552,6 +656,8 @@ function createWebhookHandler(options) {
552
656
  //#endregion
553
657
  exports.CadmeaPaymentError = CadmeaPaymentError;
554
658
  exports.createCheckoutHandler = createCheckoutHandler;
659
+ exports.createFulfillmentOrder = createFulfillmentOrder;
660
+ exports.createFulfillmentWebhookHandler = createFulfillmentWebhookHandler;
555
661
  exports.createWebhookHandler = createWebhookHandler;
556
662
  exports.ecommercePlugin = ecommercePlugin;
557
663
 
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","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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"}
1
+ {"version":3,"file":"index.cjs","names":["isUniqueConstraintError"],"sources":["../src/errors.ts","../src/checkout.ts","../src/collections.ts","../src/fulfillment.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 // Which FulfillmentProvider is shipping this order — independent of\n // `provider` (who charged the card); see types.ts's doc comment on\n // FulfillmentProvider for why these are separate axes.\n fulfillmentProvider: { type: \"text\" },\n // The fulfillment provider's own order identifier — the correlation\n // key createFulfillmentWebhookHandler dispatches inbound shipment\n // webhooks against, mirroring providerOrderRef's role for payments.\n fulfillmentProviderRef: { type: \"text\" },\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 {\n FulfillmentProvider,\n NormalizedFulfillmentWebhookEvent,\n} 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 CreateFulfillmentOrderOptions<TContext> {\n provider: FulfillmentProvider;\n orders: AnyLocalApi<TContext>;\n context: TContext;\n}\n\n/**\n * The order-paid hook implementation: submits an already-paid order's line\n * items to a `FulfillmentProvider` and persists the resulting\n * `fulfillmentProvider`/`fulfillmentProviderRef`/`fulfillmentStatus` back\n * onto the order row. Wire it as `WebhookHandlerOptions.onOrderPaid` on the\n * *payment* provider's webhook handler:\n *\n * ```ts\n * createWebhookHandler({\n * provider: stripeProvider,\n * orders, payments, webhookEvents, secret, context,\n * onOrderPaid: (order) =>\n * createFulfillmentOrder(order, { provider: printfulProvider, orders, context }),\n * });\n * ```\n *\n * Digital-goods-only stores simply never wire this — `fulfillmentProvider`\n * is plugin-optional, not a hard dependency of `ecommercePlugin`.\n */\nexport async function createFulfillmentOrder<TContext>(\n order: Record<string, unknown>,\n options: CreateFulfillmentOrderOptions<TContext>,\n): Promise<void> {\n const lineItems = (order.lineItems ?? []) as Array<{\n catalogRef?: string;\n quantity: number;\n }>;\n const shippingAddress =\n (order.shippingAddress as Record<string, string | undefined>) ?? {};\n\n const result = await options.provider.createFulfillmentOrder({\n orderId: order.id as number,\n lineItems: lineItems\n .filter((item): item is { catalogRef: string; quantity: number } =>\n Boolean(item.catalogRef),\n )\n .map((item) => ({\n catalogRef: item.catalogRef,\n quantity: item.quantity,\n })),\n shippingAddress,\n customerEmail: order.guestEmail as string | undefined,\n });\n\n await options.orders.update(options.context, order.id as number, {\n fulfillmentProvider: options.provider.name,\n fulfillmentProviderRef: result.providerFulfillmentRef,\n fulfillmentStatus: result.status,\n });\n}\n\nexport interface FulfillmentWebhookHandlerOptions<TContext> {\n provider: FulfillmentProvider;\n orders: AnyLocalApi<TContext>;\n webhookEvents: AnyLocalApi<TContext>;\n secret: string;\n /** See `WebhookHandlerOptions.context`'s identical note in webhook.ts. */\n context: TContext;\n}\n\nasync function findOrderByFulfillmentRef<TContext>(\n orders: AnyLocalApi<TContext>,\n context: TContext,\n providerFulfillmentRef: string,\n): Promise<Record<string, unknown> | undefined> {\n // Same in-memory-filter tradeoff as webhook.ts's findOneByField — revisit\n // with an indexed lookup only once volume makes it a measured problem.\n const rows = (await orders.find(context)) as Array<Record<string, unknown>>;\n return rows.find(\n (row) => row.fulfillmentProviderRef === providerFulfillmentRef,\n );\n}\n\nfunction isUniqueConstraintError(error: unknown): boolean {\n return (\n error instanceof Error &&\n error.message.includes(\"Unique constraint violated\")\n );\n}\n\nasync function dispatchFulfillmentEvent<TContext>(\n event: NormalizedFulfillmentWebhookEvent,\n options: FulfillmentWebhookHandlerOptions<TContext>,\n): Promise<void> {\n if (event.kind === \"unhandled\") return;\n\n const order = await findOrderByFulfillmentRef(\n options.orders,\n options.context,\n event.providerFulfillmentRef,\n );\n if (!order) return;\n\n await options.orders.update(options.context, order.id as number, {\n fulfillmentStatus: event.status,\n trackingNumber: event.trackingNumber,\n trackingCarrier: event.trackingCarrier,\n trackingUrl: event.trackingUrl,\n });\n}\n\n/**\n * Returns a Hono handler implementing inbound fulfillment-webhook handling\n * (shipment created/delivered/failed) against a `FulfillmentProvider` —\n * verify signature → dedup via `webhook_events` (shared with the payment\n * webhook handler; `eventId` is provider-specific so cross-provider\n * collisions aren't a concern) → look up the order by\n * `fulfillmentProviderRef` → update shipment status/tracking. Mirrors\n * `createWebhookHandler`'s structure exactly; kept as a separate function\n * rather than a generic merge of the two because the payment and\n * fulfillment event vocabularies, dedup keys, and target collections don't\n * overlap enough to share logic without branching on provider kind.\n */\nexport function createFulfillmentWebhookHandler<TContext>(\n options: FulfillmentWebhookHandlerOptions<TContext>,\n) {\n return async (c: Context): Promise<Response> => {\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 });\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 return c.json({ ok: true, duplicate: true }, 200);\n }\n throw error;\n }\n\n try {\n await dispatchFulfillmentEvent(event, options);\n } catch (error) {\n console.error(\n \"[cadmea-plugin-ecommerce] fulfillment webhook dispatch failed\",\n error,\n );\n }\n\n return c.json({ ok: true }, 200);\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 * Fires once, right after an order's status is written as \"paid\" by\n * this handler — the order-paid hook point a `FulfillmentProvider`\n * integration (e.g. `@thebes/cadmea-plugin-printful`) wires into to\n * submit the order for shipment. Receives the just-updated order row.\n * Errors thrown here are caught the same way dispatch-handler errors\n * are below — logged, never surfaced as a failure to the payment\n * provider, since the charge already succeeded and the provider must\n * not retry the whole webhook over a fulfillment-side problem.\n */\n onOrderPaid?: (order: Record<string, unknown>) => Promise<void>;\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 const updated = await options.orders.update(\n options.context,\n order.id as number,\n { status },\n );\n if (status === \"paid\" && options.onOrderPaid) {\n await options.onOrderPaid(updated);\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 const updated = await options.orders.update(\n options.context,\n order.id as number,\n { status: event.status },\n );\n if (event.status === \"paid\" && options.onOrderPaid) {\n await options.onOrderPaid(updated);\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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"}
package/dist/index.d.cts CHANGED
@@ -128,9 +128,81 @@ interface PaymentProvider {
128
128
  cancel(providerSubscriptionRef: string): Promise<void>;
129
129
  };
130
130
  }
131
+ /**
132
+ * `FulfillmentProvider` is a second instance of the "plugin-defined
133
+ * provider interface" pattern documented in EXTENDING.md alongside
134
+ * `PaymentProvider` — orthogonal to it, not a variant of it. A
135
+ * `PaymentProvider` charges a card; a `FulfillmentProvider` ships physical
136
+ * goods (print-on-demand, a 3PL, etc.) once an order is paid. An order's
137
+ * `provider` field (who charged the card) and `fulfillmentProvider` field
138
+ * (who ships it) are independent — a single Stripe-charged order might be
139
+ * fulfilled by Printful, a different POD vendor, or not at all (digital
140
+ * goods).
141
+ */
142
+ interface FulfillmentLineItem {
143
+ /** Fulfillment provider's own catalog identifier (e.g. Printful sync variant id) — opaque to this plugin, distinct from `CartLineItem.catalogRef`. */
144
+ catalogRef: string;
145
+ quantity: number;
146
+ }
147
+ interface FulfillmentOrderRequest {
148
+ /** This plugin's own `orders.id` — round-tripped so `createFulfillmentOrder` can correlate its result back to the order row that requested it. */
149
+ orderId: number;
150
+ lineItems: FulfillmentLineItem[];
151
+ shippingAddress: {
152
+ firstName?: string;
153
+ lastName?: string;
154
+ address1?: string;
155
+ address2?: string;
156
+ city?: string;
157
+ state?: string;
158
+ zip?: string;
159
+ country?: string;
160
+ phone?: string;
161
+ };
162
+ customerEmail?: string;
163
+ }
164
+ interface FulfillmentOrderResult {
165
+ /** Provider's own identifier for the fulfillment order — stored on `orders.fulfillmentProviderRef`, the correlation key inbound fulfillment webhooks dispatch against. */
166
+ providerFulfillmentRef: string;
167
+ status: "pending" | "shipped" | "delivered" | "failed";
168
+ }
169
+ /**
170
+ * The shape every fulfillment provider's raw webhook payload is translated
171
+ * into — mirrors `NormalizedWebhookEvent`'s role for `PaymentProvider`, kept
172
+ * as a separate type rather than a union member of it since the two event
173
+ * vocabularies (payment status vs. shipment status) don't overlap.
174
+ */
175
+ type NormalizedFulfillmentWebhookEvent = {
176
+ kind: "fulfillment.updated";
177
+ providerFulfillmentRef: string;
178
+ status: "shipped" | "delivered" | "failed";
179
+ trackingNumber?: string;
180
+ trackingCarrier?: string;
181
+ trackingUrl?: string;
182
+ } | {
183
+ kind: "unhandled";
184
+ rawType: string;
185
+ };
186
+ interface FulfillmentProvider {
187
+ /** e.g. "printful" — a plain string, not a closed union like `PaymentProvider.name`: fulfillment backends are a more open set (POD vendors, 3PLs) than the two payment rails this plugin ships providers for. */
188
+ readonly name: string;
189
+ /** Submits a paid order's line items for fulfillment. Called once per order, after `PaymentProvider` reports the order as paid. */
190
+ createFulfillmentOrder(request: FulfillmentOrderRequest): Promise<FulfillmentOrderResult>;
191
+ /** Verifies an inbound webhook's signature — same contract as `PaymentProvider.verifyWebhookSignature`. */
192
+ verifyWebhookSignature(args: {
193
+ rawBody: string;
194
+ headers: Headers;
195
+ secret: string;
196
+ }): Promise<boolean>;
197
+ /** Parses an already-verified raw webhook body into a normalized event, plus the provider's own event id for dedup. */
198
+ parseWebhookEvent(rawBody: string): {
199
+ eventId: string;
200
+ event: NormalizedFulfillmentWebhookEvent;
201
+ };
202
+ }
131
203
  //#endregion
132
204
  //#region src/checkout.d.ts
133
- type AnyLocalApi$1<TContext> = LocalApi<any, TContext>;
205
+ type AnyLocalApi$2<TContext> = LocalApi<any, TContext>;
134
206
  interface CheckoutRequestBody {
135
207
  lineItems: CartLineItem[];
136
208
  paymentSourceToken: string;
@@ -141,8 +213,8 @@ interface CheckoutRequestBody {
141
213
  }
142
214
  interface CheckoutHandlerOptions<TContext> {
143
215
  provider: PaymentProvider;
144
- orders: AnyLocalApi$1<TContext>;
145
- payments: AnyLocalApi$1<TContext>;
216
+ orders: AnyLocalApi$2<TContext>;
217
+ payments: AnyLocalApi$2<TContext>;
146
218
  /**
147
219
  * Resolves the per-request access context passed to `orders`/`payments`
148
220
  * — called once per request, the same shape and timing as
@@ -229,6 +301,55 @@ declare class CadmeaPaymentError extends Error {
229
301
  constructor(message: string, cause?: unknown | undefined);
230
302
  }
231
303
  //#endregion
304
+ //#region src/fulfillment.d.ts
305
+ type AnyLocalApi$1<TContext> = LocalApi<any, TContext>;
306
+ interface CreateFulfillmentOrderOptions<TContext> {
307
+ provider: FulfillmentProvider;
308
+ orders: AnyLocalApi$1<TContext>;
309
+ context: TContext;
310
+ }
311
+ /**
312
+ * The order-paid hook implementation: submits an already-paid order's line
313
+ * items to a `FulfillmentProvider` and persists the resulting
314
+ * `fulfillmentProvider`/`fulfillmentProviderRef`/`fulfillmentStatus` back
315
+ * onto the order row. Wire it as `WebhookHandlerOptions.onOrderPaid` on the
316
+ * *payment* provider's webhook handler:
317
+ *
318
+ * ```ts
319
+ * createWebhookHandler({
320
+ * provider: stripeProvider,
321
+ * orders, payments, webhookEvents, secret, context,
322
+ * onOrderPaid: (order) =>
323
+ * createFulfillmentOrder(order, { provider: printfulProvider, orders, context }),
324
+ * });
325
+ * ```
326
+ *
327
+ * Digital-goods-only stores simply never wire this — `fulfillmentProvider`
328
+ * is plugin-optional, not a hard dependency of `ecommercePlugin`.
329
+ */
330
+ declare function createFulfillmentOrder<TContext>(order: Record<string, unknown>, options: CreateFulfillmentOrderOptions<TContext>): Promise<void>;
331
+ interface FulfillmentWebhookHandlerOptions<TContext> {
332
+ provider: FulfillmentProvider;
333
+ orders: AnyLocalApi$1<TContext>;
334
+ webhookEvents: AnyLocalApi$1<TContext>;
335
+ secret: string;
336
+ /** See `WebhookHandlerOptions.context`'s identical note in webhook.ts. */
337
+ context: TContext;
338
+ }
339
+ /**
340
+ * Returns a Hono handler implementing inbound fulfillment-webhook handling
341
+ * (shipment created/delivered/failed) against a `FulfillmentProvider` —
342
+ * verify signature → dedup via `webhook_events` (shared with the payment
343
+ * webhook handler; `eventId` is provider-specific so cross-provider
344
+ * collisions aren't a concern) → look up the order by
345
+ * `fulfillmentProviderRef` → update shipment status/tracking. Mirrors
346
+ * `createWebhookHandler`'s structure exactly; kept as a separate function
347
+ * rather than a generic merge of the two because the payment and
348
+ * fulfillment event vocabularies, dedup keys, and target collections don't
349
+ * overlap enough to share logic without branching on provider kind.
350
+ */
351
+ declare function createFulfillmentWebhookHandler<TContext>(options: FulfillmentWebhookHandlerOptions<TContext>): (c: Context) => Promise<Response>;
352
+ //#endregion
232
353
  //#region src/webhook.d.ts
233
354
  type AnyLocalApi<TContext> = LocalApi<any, TContext>;
234
355
  interface WebhookHandlerOptions<TContext> {
@@ -249,6 +370,17 @@ interface WebhookHandlerOptions<TContext> {
249
370
  * own `access` config accepts for system-level writes.
250
371
  */
251
372
  context: TContext;
373
+ /**
374
+ * Fires once, right after an order's status is written as "paid" by
375
+ * this handler — the order-paid hook point a `FulfillmentProvider`
376
+ * integration (e.g. `@thebes/cadmea-plugin-printful`) wires into to
377
+ * submit the order for shipment. Receives the just-updated order row.
378
+ * Errors thrown here are caught the same way dispatch-handler errors
379
+ * are below — logged, never surfaced as a failure to the payment
380
+ * provider, since the charge already succeeded and the provider must
381
+ * not retry the whole webhook over a fulfillment-side problem.
382
+ */
383
+ onOrderPaid?: (order: Record<string, unknown>) => Promise<void>;
252
384
  }
253
385
  /**
254
386
  * Returns a Hono handler implementing inbound webhook handling against a
@@ -264,5 +396,5 @@ interface WebhookHandlerOptions<TContext> {
264
396
  */
265
397
  declare function createWebhookHandler<TContext>(options: WebhookHandlerOptions<TContext>): (c: Context) => Promise<Response>;
266
398
  //#endregion
267
- export { CadmeaPaymentError, CartLineItem, CatalogPriceCheck, CheckoutHandlerOptions, CheckoutRequest, CheckoutRequestBody, CheckoutResult, EcommercePluginOptions, Money, NormalizedWebhookEvent, PaymentProvider, WebhookHandlerOptions, createCheckoutHandler, createWebhookHandler, ecommercePlugin };
399
+ export { CadmeaPaymentError, CartLineItem, CatalogPriceCheck, CheckoutHandlerOptions, CheckoutRequest, CheckoutRequestBody, CheckoutResult, CreateFulfillmentOrderOptions, EcommercePluginOptions, FulfillmentLineItem, FulfillmentOrderRequest, FulfillmentOrderResult, FulfillmentProvider, FulfillmentWebhookHandlerOptions, Money, NormalizedFulfillmentWebhookEvent, NormalizedWebhookEvent, PaymentProvider, WebhookHandlerOptions, createCheckoutHandler, createFulfillmentOrder, createFulfillmentWebhookHandler, createWebhookHandler, ecommercePlugin };
268
400
  //# sourceMappingURL=index.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.cts","names":[],"sources":["../src/types.ts","../src/checkout.ts","../src/collections.ts","../src/errors.ts","../src/webhook.ts"],"mappings":"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"}
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../src/types.ts","../src/checkout.ts","../src/collections.ts","../src/errors.ts","../src/fulfillment.ts","../src/webhook.ts"],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -128,9 +128,81 @@ interface PaymentProvider {
128
128
  cancel(providerSubscriptionRef: string): Promise<void>;
129
129
  };
130
130
  }
131
+ /**
132
+ * `FulfillmentProvider` is a second instance of the "plugin-defined
133
+ * provider interface" pattern documented in EXTENDING.md alongside
134
+ * `PaymentProvider` — orthogonal to it, not a variant of it. A
135
+ * `PaymentProvider` charges a card; a `FulfillmentProvider` ships physical
136
+ * goods (print-on-demand, a 3PL, etc.) once an order is paid. An order's
137
+ * `provider` field (who charged the card) and `fulfillmentProvider` field
138
+ * (who ships it) are independent — a single Stripe-charged order might be
139
+ * fulfilled by Printful, a different POD vendor, or not at all (digital
140
+ * goods).
141
+ */
142
+ interface FulfillmentLineItem {
143
+ /** Fulfillment provider's own catalog identifier (e.g. Printful sync variant id) — opaque to this plugin, distinct from `CartLineItem.catalogRef`. */
144
+ catalogRef: string;
145
+ quantity: number;
146
+ }
147
+ interface FulfillmentOrderRequest {
148
+ /** This plugin's own `orders.id` — round-tripped so `createFulfillmentOrder` can correlate its result back to the order row that requested it. */
149
+ orderId: number;
150
+ lineItems: FulfillmentLineItem[];
151
+ shippingAddress: {
152
+ firstName?: string;
153
+ lastName?: string;
154
+ address1?: string;
155
+ address2?: string;
156
+ city?: string;
157
+ state?: string;
158
+ zip?: string;
159
+ country?: string;
160
+ phone?: string;
161
+ };
162
+ customerEmail?: string;
163
+ }
164
+ interface FulfillmentOrderResult {
165
+ /** Provider's own identifier for the fulfillment order — stored on `orders.fulfillmentProviderRef`, the correlation key inbound fulfillment webhooks dispatch against. */
166
+ providerFulfillmentRef: string;
167
+ status: "pending" | "shipped" | "delivered" | "failed";
168
+ }
169
+ /**
170
+ * The shape every fulfillment provider's raw webhook payload is translated
171
+ * into — mirrors `NormalizedWebhookEvent`'s role for `PaymentProvider`, kept
172
+ * as a separate type rather than a union member of it since the two event
173
+ * vocabularies (payment status vs. shipment status) don't overlap.
174
+ */
175
+ type NormalizedFulfillmentWebhookEvent = {
176
+ kind: "fulfillment.updated";
177
+ providerFulfillmentRef: string;
178
+ status: "shipped" | "delivered" | "failed";
179
+ trackingNumber?: string;
180
+ trackingCarrier?: string;
181
+ trackingUrl?: string;
182
+ } | {
183
+ kind: "unhandled";
184
+ rawType: string;
185
+ };
186
+ interface FulfillmentProvider {
187
+ /** e.g. "printful" — a plain string, not a closed union like `PaymentProvider.name`: fulfillment backends are a more open set (POD vendors, 3PLs) than the two payment rails this plugin ships providers for. */
188
+ readonly name: string;
189
+ /** Submits a paid order's line items for fulfillment. Called once per order, after `PaymentProvider` reports the order as paid. */
190
+ createFulfillmentOrder(request: FulfillmentOrderRequest): Promise<FulfillmentOrderResult>;
191
+ /** Verifies an inbound webhook's signature — same contract as `PaymentProvider.verifyWebhookSignature`. */
192
+ verifyWebhookSignature(args: {
193
+ rawBody: string;
194
+ headers: Headers;
195
+ secret: string;
196
+ }): Promise<boolean>;
197
+ /** Parses an already-verified raw webhook body into a normalized event, plus the provider's own event id for dedup. */
198
+ parseWebhookEvent(rawBody: string): {
199
+ eventId: string;
200
+ event: NormalizedFulfillmentWebhookEvent;
201
+ };
202
+ }
131
203
  //#endregion
132
204
  //#region src/checkout.d.ts
133
- type AnyLocalApi$1<TContext> = LocalApi<any, TContext>;
205
+ type AnyLocalApi$2<TContext> = LocalApi<any, TContext>;
134
206
  interface CheckoutRequestBody {
135
207
  lineItems: CartLineItem[];
136
208
  paymentSourceToken: string;
@@ -141,8 +213,8 @@ interface CheckoutRequestBody {
141
213
  }
142
214
  interface CheckoutHandlerOptions<TContext> {
143
215
  provider: PaymentProvider;
144
- orders: AnyLocalApi$1<TContext>;
145
- payments: AnyLocalApi$1<TContext>;
216
+ orders: AnyLocalApi$2<TContext>;
217
+ payments: AnyLocalApi$2<TContext>;
146
218
  /**
147
219
  * Resolves the per-request access context passed to `orders`/`payments`
148
220
  * — called once per request, the same shape and timing as
@@ -229,6 +301,55 @@ declare class CadmeaPaymentError extends Error {
229
301
  constructor(message: string, cause?: unknown | undefined);
230
302
  }
231
303
  //#endregion
304
+ //#region src/fulfillment.d.ts
305
+ type AnyLocalApi$1<TContext> = LocalApi<any, TContext>;
306
+ interface CreateFulfillmentOrderOptions<TContext> {
307
+ provider: FulfillmentProvider;
308
+ orders: AnyLocalApi$1<TContext>;
309
+ context: TContext;
310
+ }
311
+ /**
312
+ * The order-paid hook implementation: submits an already-paid order's line
313
+ * items to a `FulfillmentProvider` and persists the resulting
314
+ * `fulfillmentProvider`/`fulfillmentProviderRef`/`fulfillmentStatus` back
315
+ * onto the order row. Wire it as `WebhookHandlerOptions.onOrderPaid` on the
316
+ * *payment* provider's webhook handler:
317
+ *
318
+ * ```ts
319
+ * createWebhookHandler({
320
+ * provider: stripeProvider,
321
+ * orders, payments, webhookEvents, secret, context,
322
+ * onOrderPaid: (order) =>
323
+ * createFulfillmentOrder(order, { provider: printfulProvider, orders, context }),
324
+ * });
325
+ * ```
326
+ *
327
+ * Digital-goods-only stores simply never wire this — `fulfillmentProvider`
328
+ * is plugin-optional, not a hard dependency of `ecommercePlugin`.
329
+ */
330
+ declare function createFulfillmentOrder<TContext>(order: Record<string, unknown>, options: CreateFulfillmentOrderOptions<TContext>): Promise<void>;
331
+ interface FulfillmentWebhookHandlerOptions<TContext> {
332
+ provider: FulfillmentProvider;
333
+ orders: AnyLocalApi$1<TContext>;
334
+ webhookEvents: AnyLocalApi$1<TContext>;
335
+ secret: string;
336
+ /** See `WebhookHandlerOptions.context`'s identical note in webhook.ts. */
337
+ context: TContext;
338
+ }
339
+ /**
340
+ * Returns a Hono handler implementing inbound fulfillment-webhook handling
341
+ * (shipment created/delivered/failed) against a `FulfillmentProvider` —
342
+ * verify signature → dedup via `webhook_events` (shared with the payment
343
+ * webhook handler; `eventId` is provider-specific so cross-provider
344
+ * collisions aren't a concern) → look up the order by
345
+ * `fulfillmentProviderRef` → update shipment status/tracking. Mirrors
346
+ * `createWebhookHandler`'s structure exactly; kept as a separate function
347
+ * rather than a generic merge of the two because the payment and
348
+ * fulfillment event vocabularies, dedup keys, and target collections don't
349
+ * overlap enough to share logic without branching on provider kind.
350
+ */
351
+ declare function createFulfillmentWebhookHandler<TContext>(options: FulfillmentWebhookHandlerOptions<TContext>): (c: Context) => Promise<Response>;
352
+ //#endregion
232
353
  //#region src/webhook.d.ts
233
354
  type AnyLocalApi<TContext> = LocalApi<any, TContext>;
234
355
  interface WebhookHandlerOptions<TContext> {
@@ -249,6 +370,17 @@ interface WebhookHandlerOptions<TContext> {
249
370
  * own `access` config accepts for system-level writes.
250
371
  */
251
372
  context: TContext;
373
+ /**
374
+ * Fires once, right after an order's status is written as "paid" by
375
+ * this handler — the order-paid hook point a `FulfillmentProvider`
376
+ * integration (e.g. `@thebes/cadmea-plugin-printful`) wires into to
377
+ * submit the order for shipment. Receives the just-updated order row.
378
+ * Errors thrown here are caught the same way dispatch-handler errors
379
+ * are below — logged, never surfaced as a failure to the payment
380
+ * provider, since the charge already succeeded and the provider must
381
+ * not retry the whole webhook over a fulfillment-side problem.
382
+ */
383
+ onOrderPaid?: (order: Record<string, unknown>) => Promise<void>;
252
384
  }
253
385
  /**
254
386
  * Returns a Hono handler implementing inbound webhook handling against a
@@ -264,5 +396,5 @@ interface WebhookHandlerOptions<TContext> {
264
396
  */
265
397
  declare function createWebhookHandler<TContext>(options: WebhookHandlerOptions<TContext>): (c: Context) => Promise<Response>;
266
398
  //#endregion
267
- export { CadmeaPaymentError, CartLineItem, CatalogPriceCheck, CheckoutHandlerOptions, CheckoutRequest, CheckoutRequestBody, CheckoutResult, EcommercePluginOptions, Money, NormalizedWebhookEvent, PaymentProvider, WebhookHandlerOptions, createCheckoutHandler, createWebhookHandler, ecommercePlugin };
399
+ export { CadmeaPaymentError, CartLineItem, CatalogPriceCheck, CheckoutHandlerOptions, CheckoutRequest, CheckoutRequestBody, CheckoutResult, CreateFulfillmentOrderOptions, EcommercePluginOptions, FulfillmentLineItem, FulfillmentOrderRequest, FulfillmentOrderResult, FulfillmentProvider, FulfillmentWebhookHandlerOptions, Money, NormalizedFulfillmentWebhookEvent, NormalizedWebhookEvent, PaymentProvider, WebhookHandlerOptions, createCheckoutHandler, createFulfillmentOrder, createFulfillmentWebhookHandler, createWebhookHandler, ecommercePlugin };
268
400
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/checkout.ts","../src/collections.ts","../src/errors.ts","../src/webhook.ts"],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/checkout.ts","../src/collections.ts","../src/errors.ts","../src/fulfillment.ts","../src/webhook.ts"],"mappings":"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"}
package/dist/index.js CHANGED
@@ -291,6 +291,8 @@ function buildOrdersCollection(slug, customersSlug) {
291
291
  "failed"
292
292
  ]
293
293
  },
294
+ fulfillmentProvider: { type: "text" },
295
+ fulfillmentProviderRef: { type: "text" },
294
296
  trackingNumber: { type: "text" },
295
297
  trackingCarrier: { type: "text" },
296
298
  trackingUrl: { type: "text" },
@@ -472,6 +474,104 @@ function ecommercePlugin(options = {}) {
472
474
  };
473
475
  }
474
476
  //#endregion
477
+ //#region src/fulfillment.ts
478
+ /**
479
+ * The order-paid hook implementation: submits an already-paid order's line
480
+ * items to a `FulfillmentProvider` and persists the resulting
481
+ * `fulfillmentProvider`/`fulfillmentProviderRef`/`fulfillmentStatus` back
482
+ * onto the order row. Wire it as `WebhookHandlerOptions.onOrderPaid` on the
483
+ * *payment* provider's webhook handler:
484
+ *
485
+ * ```ts
486
+ * createWebhookHandler({
487
+ * provider: stripeProvider,
488
+ * orders, payments, webhookEvents, secret, context,
489
+ * onOrderPaid: (order) =>
490
+ * createFulfillmentOrder(order, { provider: printfulProvider, orders, context }),
491
+ * });
492
+ * ```
493
+ *
494
+ * Digital-goods-only stores simply never wire this — `fulfillmentProvider`
495
+ * is plugin-optional, not a hard dependency of `ecommercePlugin`.
496
+ */
497
+ async function createFulfillmentOrder(order, options) {
498
+ const lineItems = order.lineItems ?? [];
499
+ const shippingAddress = order.shippingAddress ?? {};
500
+ const result = await options.provider.createFulfillmentOrder({
501
+ orderId: order.id,
502
+ lineItems: lineItems.filter((item) => Boolean(item.catalogRef)).map((item) => ({
503
+ catalogRef: item.catalogRef,
504
+ quantity: item.quantity
505
+ })),
506
+ shippingAddress,
507
+ customerEmail: order.guestEmail
508
+ });
509
+ await options.orders.update(options.context, order.id, {
510
+ fulfillmentProvider: options.provider.name,
511
+ fulfillmentProviderRef: result.providerFulfillmentRef,
512
+ fulfillmentStatus: result.status
513
+ });
514
+ }
515
+ async function findOrderByFulfillmentRef(orders, context, providerFulfillmentRef) {
516
+ return (await orders.find(context)).find((row) => row.fulfillmentProviderRef === providerFulfillmentRef);
517
+ }
518
+ function isUniqueConstraintError$1(error) {
519
+ return error instanceof Error && error.message.includes("Unique constraint violated");
520
+ }
521
+ async function dispatchFulfillmentEvent(event, options) {
522
+ if (event.kind === "unhandled") return;
523
+ const order = await findOrderByFulfillmentRef(options.orders, options.context, event.providerFulfillmentRef);
524
+ if (!order) return;
525
+ await options.orders.update(options.context, order.id, {
526
+ fulfillmentStatus: event.status,
527
+ trackingNumber: event.trackingNumber,
528
+ trackingCarrier: event.trackingCarrier,
529
+ trackingUrl: event.trackingUrl
530
+ });
531
+ }
532
+ /**
533
+ * Returns a Hono handler implementing inbound fulfillment-webhook handling
534
+ * (shipment created/delivered/failed) against a `FulfillmentProvider` —
535
+ * verify signature → dedup via `webhook_events` (shared with the payment
536
+ * webhook handler; `eventId` is provider-specific so cross-provider
537
+ * collisions aren't a concern) → look up the order by
538
+ * `fulfillmentProviderRef` → update shipment status/tracking. Mirrors
539
+ * `createWebhookHandler`'s structure exactly; kept as a separate function
540
+ * rather than a generic merge of the two because the payment and
541
+ * fulfillment event vocabularies, dedup keys, and target collections don't
542
+ * overlap enough to share logic without branching on provider kind.
543
+ */
544
+ function createFulfillmentWebhookHandler(options) {
545
+ return async (c) => {
546
+ const rawBody = await c.req.text();
547
+ if (!await options.provider.verifyWebhookSignature({
548
+ rawBody,
549
+ headers: c.req.raw.headers,
550
+ secret: options.secret
551
+ })) return c.json({ error: "Invalid signature" }, 401);
552
+ const { eventId, event } = options.provider.parseWebhookEvent(rawBody);
553
+ try {
554
+ await options.webhookEvents.create(options.context, {
555
+ provider: options.provider.name,
556
+ eventId,
557
+ eventType: event.kind
558
+ });
559
+ } catch (error) {
560
+ if (isUniqueConstraintError$1(error)) return c.json({
561
+ ok: true,
562
+ duplicate: true
563
+ }, 200);
564
+ throw error;
565
+ }
566
+ try {
567
+ await dispatchFulfillmentEvent(event, options);
568
+ } catch (error) {
569
+ console.error("[cadmea-plugin-ecommerce] fulfillment webhook dispatch failed", error);
570
+ }
571
+ return c.json({ ok: true }, 200);
572
+ };
573
+ }
574
+ //#endregion
475
575
  //#region src/webhook.ts
476
576
  function isUniqueConstraintError(error) {
477
577
  return error instanceof Error && error.message.includes("Unique constraint violated");
@@ -487,13 +587,17 @@ async function dispatchEvent(event, options) {
487
587
  const order = await findOneByField(options.orders, options.context, "providerPaymentRef", event.providerPaymentRef);
488
588
  if (order) {
489
589
  const status = event.status === "succeeded" ? "paid" : event.status === "refunded" ? "refunded" : "failed";
490
- await options.orders.update(options.context, order.id, { status });
590
+ const updated = await options.orders.update(options.context, order.id, { status });
591
+ if (status === "paid" && options.onOrderPaid) await options.onOrderPaid(updated);
491
592
  }
492
593
  return;
493
594
  }
494
595
  case "order.updated": {
495
596
  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 });
597
+ if (order) {
598
+ const updated = await options.orders.update(options.context, order.id, { status: event.status });
599
+ if (event.status === "paid" && options.onOrderPaid) await options.onOrderPaid(updated);
600
+ }
497
601
  return;
498
602
  }
499
603
  case "subscription.updated": {
@@ -549,6 +653,6 @@ function createWebhookHandler(options) {
549
653
  };
550
654
  }
551
655
  //#endregion
552
- export { CadmeaPaymentError, createCheckoutHandler, createWebhookHandler, ecommercePlugin };
656
+ export { CadmeaPaymentError, createCheckoutHandler, createFulfillmentOrder, createFulfillmentWebhookHandler, createWebhookHandler, ecommercePlugin };
553
657
 
554
658
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +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":";;;;;;;;;;;;;;;;;;;;;AAsBA,IAAa,qBAAb,cAAwC,MAAM;CAG1B;CAFlB,YACE,SACA,OACA;EACA,MAAM,OAAO;EAFG,KAAA,QAAA;EAGhB,KAAK,OAAO;CACd;AACF;;;ACaA,SAAS,cAAc,WAAmC;CACxD,OAAO,UAAU,QACd,KAAK,SAAS,MAAM,KAAK,gBAAgB,SAAS,KAAK,UACxD,CACF;AACF;AAEA,SAAS,sBAA8B;CACrC,OAAO,OAAO,OAAO,WAAW,CAAC,CAAC,QAAQ,MAAM,EAAE,CAAC,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC,YAAY;AAC/E;;;;;;;;;;;;;;;;;;AAmBA,SAAgB,sBACd,SACA;CACA,OAAO,OAAO,MAAkC;EAC9C,IAAI,QAAQ,WAAW;GACrB,MAAM,KAAK,EAAE,IAAI,OAAO,kBAAkB,KAAK;GAC/C,MAAM,MAAM,GAAG,QAAQ,UAAU,aAAa,WAAW,GAAG;GAO5D,IAAI,EAAC,MANgB,eACnB,QAAQ,UAAU,IAClB,KACA,QAAQ,UAAU,OAClB,QAAQ,UAAU,aACpB,EAAA,CACY,SACV,OAAO,EAAE,KAAK,EAAE,OAAO,sBAAsB,GAAG,GAAG;EAEvD;EAEA,MAAM,OAAO,MAAM,EAAE,IAAI,KAA0B;EAKnD,IAAI,CAAC,MAAM,QAAQ,KAAK,SAAS,KAAK,KAAK,UAAU,WAAW,GAC9D,OAAO,EAAE,KACP,EAAE,OAAO,uDAAuD,GAChE,GACF;EAEF,IAAI,CAAC,KAAK,gBACR,OAAO,EAAE,KACP,EAAE,OAAO,kDAAkD,GAC3D,GACF;EAGF,IAAI;GAGF,MAAM,OAAO,KAAK,UAAU,KAAK,SAAS,KAAK,UAAU;GACzD,MAAM,cAAc,MAAM,QAAQ,SAAS,mBAAmB,IAAI;GAClE,MAAM,aAAa,IAAI,IACrB,YAAY,KAAK,UAAU,CAAC,MAAM,YAAY,KAAK,CAAC,CACtD;GACA,KAAK,MAAM,QAAQ,KAAK,WAAW;IACjC,MAAM,QAAQ,WAAW,IAAI,KAAK,UAAU;IAC5C,IAAI,CAAC,OACH,MAAM,IAAI,mBACR,yBAAyB,KAAK,WAAW,EAC3C;IAEF,IAAI,MAAM,gBAAgB,WAAW,KAAK,gBAAgB,QACxD,MAAM,IAAI,mBACR,uBAAuB,KAAK,WAAW,sBACzC;IAEF,IACE,MAAM,sBAAsB,KAAA,KAC5B,MAAM,oBAAoB,KAAK,UAE/B,MAAM,IAAI,mBACR,+BAA+B,KAAK,WAAW,EACjD;GAEJ;EACF,SAAS,OAAO;GACd,IAAI,iBAAiB,oBACnB,OAAO,EAAE,KAAK,EAAE,OAAO,MAAM,QAAQ,GAAG,GAAG;GAE7C,MAAM;EACR;EAEA,IAAI,KAAK,eAMP,MAAM,QAAQ,SAAS,qBACrB,KAAK,eACL,KAAK,cACP;EAGF,MAAM,SAAS,MAAM,QAAQ,SAAS,SAAS;GAC7C,WAAW,KAAK;GAChB,oBAAoB,KAAK;GACzB,eAAe,KAAK;GACpB,gBAAgB,KAAK;GACrB,UAAU,KAAK;EACjB,CAAC;EAED,MAAM,UAAU,MAAM,QAAQ,eAAe,CAAC;EAC9C,MAAM,YAAY;GAChB,aAAa,oBAAoB;GACjC,QAAQ,OAAO,WAAW,cAAc,SAAS;GACjD,YAAY,OAAO,OAAO;GAC1B,eAAe,cAAc,KAAK,SAAS;GAC3C,UAAU,OAAO,OAAO;GACxB,UAAU,QAAQ,SAAS;GAC3B,kBAAkB,OAAO;GACzB,oBAAoB,OAAO;GAC3B,YAAY,KAAK;GACjB,WAAW,KAAK,UAAU,KAAK,UAAU;IACvC,aAAa,KAAK;IAClB,UAAU,KAAK;IACf,gBAAgB,KAAK,gBAAgB;IACrC,iBAAiB,KAAK,gBAAgB,SAAS,KAAK;IACpD,YAAY,KAAK;GACnB,EAAE;GACF,iBAAiB,KAAK;EACxB;EAEA,IAAI;GACF,MAAM,QAAQ,MAAM,QAAQ,OAAO,OAAO,SAAS,SAAS;GAC5D,MAAM,QAAQ,SAAS,OAAO,SAAS;IACrC,UAAU,QAAQ,SAAS;IAC3B,oBAAoB,OAAO;IAC3B,kBAAkB,OAAO;IACzB,OAAO,MAAM;IACb,QAAQ,OAAO;IACf,aAAa,OAAO,OAAO;IAC3B,UAAU,OAAO,OAAO;IACxB,aAAa,OAAO;GACtB,CAAC;GACD,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,GAAG;EAC9B,SAAS,OAAO;GAGd,OAAO,EAAE,KACP;IACE,SACE;IACF,oBAAoB,OAAO;IAC3B,kBAAkB,OAAO;IACzB,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;GAC9D,GACA,GACF;EACF;CACF;AACF;;;ACtLA,MAAM,WAAW;CACf,UAAU;CACV,QAAQ;CACR,WAAW;CACX,UAAU;CACV,eAAe;CACf,eAAe;CACf,OAAO;AACT;AAEA,SAAS,wBAAwB,MAAgC;CAC/D,OAAO;EACL;EACA,QAAQ;GACN,IAAI;IAAE,MAAM;IAAU,eAAe;GAAK;GAC1C,MAAM;IAAE,MAAM;IAAQ,UAAU;GAAK;GACrC,aAAa,EAAE,MAAM,OAAO;GAC5B,QAAQ;IACN,MAAM;IACN,SAAS;KAAC;KAAS;KAAU;IAAU;IACvC,UAAU;IACV,cAAc;GAChB;GAEA,UAAU;IACR,MAAM;IACN,UAAU;IACV,QAAQ;KACN,KAAK;MAAE,MAAM;MAAQ,UAAU;KAAK;KACpC,YAAY;MAAE,MAAM;MAAQ,UAAU;KAAK;KAC3C,YAAY;MAAE,MAAM;MAAU,UAAU;KAAK;KAC7C,UAAU;MAAE,MAAM;MAAQ,cAAc;KAAM;KAC9C,gBAAgB,EAAE,MAAM,SAAS;IACnC;GACF;GACA,WAAW;IAAE,MAAM;IAAQ,MAAM;IAAa,cAAc;GAAM;EACpE;CACF;AACF;AAEA,SAAS,sBACP,MACA,eACkB;CAClB,OAAO;EACL;EACA,QAAQ;GACN,IAAI;IAAE,MAAM;IAAU,eAAe;GAAK;GAC1C,aAAa;IAAE,MAAM;IAAQ,UAAU;IAAM,QAAQ;GAAK;GAC1D,QAAQ;IACN,MAAM;IACN,SAAS;KACP;KACA;KACA;KACA;KACA;IACF;IACA,UAAU;IACV,cAAc;GAChB;GACA,YAAY;IAAE,MAAM;IAAU,UAAU;GAAK;GAC7C,eAAe;IAAE,MAAM;IAAU,UAAU;GAAK;GAChD,UAAU,EAAE,MAAM,SAAS;GAC3B,UAAU;IAAE,MAAM;IAAQ,cAAc;GAAM;GAI9C,UAAU;IACR,MAAM;IACN,SAAS,CAAC,UAAU,QAAQ;IAC5B,UAAU;GACZ;GACA,kBAAkB,EAAE,MAAM,OAAO;GACjC,oBAAoB,EAAE,MAAM,OAAO;GACnC,UAAU;IAAE,MAAM;IAAgB,YAAY;GAAc;GAI5D,YAAY,EAAE,MAAM,OAAO;GAC3B,WAAW;IACT,MAAM;IACN,UAAU;IACV,QAAQ;KACN,aAAa;MAAE,MAAM;MAAQ,UAAU;KAAK;KAC5C,UAAU;MAAE,MAAM;MAAU,UAAU;KAAK;KAC3C,gBAAgB;MAAE,MAAM;MAAU,UAAU;KAAK;KACjD,iBAAiB;MAAE,MAAM;MAAU,UAAU;KAAK;KAClD,YAAY,EAAE,MAAM,OAAO;IAC7B;GACF;GAIA,iBAAiB;IACf,MAAM;IACN,QAAQ;KACN,WAAW,EAAE,MAAM,OAAO;KAC1B,UAAU,EAAE,MAAM,OAAO;KACzB,UAAU,EAAE,MAAM,OAAO;KACzB,UAAU,EAAE,MAAM,OAAO;KACzB,MAAM,EAAE,MAAM,OAAO;KACrB,OAAO,EAAE,MAAM,OAAO;KACtB,KAAK,EAAE,MAAM,OAAO;KACpB,SAAS;MAAE,MAAM;MAAQ,cAAc;KAAK;KAC5C,OAAO,EAAE,MAAM,OAAO;IACxB;GACF;GACA,mBAAmB;IACjB,MAAM;IACN,SAAS;KAAC;KAAW;KAAW;KAAa;IAAQ;GACvD;GACA,gBAAgB,EAAE,MAAM,OAAO;GAC/B,iBAAiB,EAAE,MAAM,OAAO;GAChC,aAAa,EAAE,MAAM,OAAO;GAC5B,WAAW;IAAE,MAAM;IAAQ,MAAM;IAAa,cAAc;GAAM;EACpE;CACF;AACF;AAEA,SAAS,yBACP,MACA,WACkB;CAClB,OAAO;EACL;EACA,QAAQ;GACN,IAAI;IAAE,MAAM;IAAU,eAAe;GAAK;GAC1C,OAAO;IAAE,MAAM;IAAQ,UAAU;IAAM,QAAQ;GAAK;GACpD,UAAU;IAAE,MAAM;IAAU,SAAS,CAAC,UAAU,QAAQ;GAAE;GAC1D,qBAAqB,EAAE,MAAM,OAAO;GACpC,YAAY;IAAE,MAAM;IAAgB,YAAY;GAAU;GAG1D,mBAAmB,EAAE,MAAM,OAAO;GAClC,eAAe;IAAE,MAAM;IAAU,cAAc;GAAE;GACjD,WAAW;IAAE,MAAM;IAAQ,MAAM;IAAa,cAAc;GAAM;EACpE;CACF;AACF;AAEA,SAAS,wBACP,MACA,YACkB;CAClB,OAAO;EACL;EACA,QAAQ;GACN,IAAI;IAAE,MAAM;IAAU,eAAe;GAAK;GAC1C,UAAU;IACR,MAAM;IACN,SAAS,CAAC,UAAU,QAAQ;IAC5B,UAAU;GACZ;GACA,oBAAoB;IAAE,MAAM;IAAQ,UAAU;GAAK;GACnD,kBAAkB,EAAE,MAAM,OAAO;GACjC,OAAO;IAAE,MAAM;IAAgB,YAAY;GAAW;GAKtD,QAAQ,EAAE,MAAM,OAAO;GACvB,aAAa;IAAE,MAAM;IAAU,UAAU;GAAK;GAC9C,UAAU;IAAE,MAAM;IAAQ,cAAc;GAAM;GAI9C,aAAa,EAAE,MAAM,OAAO;GAC5B,WAAW;IAAE,MAAM;IAAQ,MAAM;IAAa,cAAc;GAAM;EACpE;CACF;AACF;AAEA,SAAS,6BAA6B,MAAgC;CACpE,OAAO;EACL;EACA,QAAQ;GACN,IAAI;IAAE,MAAM;IAAU,eAAe;GAAK;GAC1C,UAAU;IACR,MAAM;IACN,SAAS,CAAC,UAAU,QAAQ;IAC5B,UAAU;GACZ;GAKA,SAAS;IAAE,MAAM;IAAQ,UAAU;IAAM,QAAQ;GAAK;GACtD,WAAW,EAAE,MAAM,OAAO;GAC1B,YAAY;IAAE,MAAM;IAAQ,MAAM;IAAa,cAAc;GAAM;EACrE;CACF;AACF;AAEA,SAAS,6BACP,MACA,eACkB;CAClB,OAAO;EACL;EACA,QAAQ;GACN,IAAI;IAAE,MAAM;IAAU,eAAe;GAAK;GAC1C,UAAU;IACR,MAAM;IACN,SAAS,CAAC,UAAU,QAAQ;IAC5B,UAAU;GACZ;GACA,yBAAyB;IAAE,MAAM;IAAQ,UAAU;GAAK;GACxD,UAAU;IAAE,MAAM;IAAgB,YAAY;GAAc;GAC5D,QAAQ,EAAE,MAAM,OAAO;GACvB,oBAAoB;IAAE,MAAM;IAAQ,MAAM;GAAY;GACtD,WAAW;IAAE,MAAM;IAAQ,MAAM;IAAa,cAAc;GAAM;EACpE;CACF;AACF;;;;;;;AAQA,SAAgB,gBACd,UAAkC,CAAC,GACrB;CACd,MAAM,QAAQ;EACZ,UAAU,QAAQ,gBAAgB,SAAS;EAC3C,QAAQ,QAAQ,cAAc,SAAS;EACvC,WAAW,QAAQ,iBAAiB,SAAS;EAC7C,UAAU,QAAQ,gBAAgB,SAAS;EAC3C,eAAe,QAAQ,qBAAqB,SAAS;EACrD,eAAe,QAAQ,qBAAqB,SAAS;EACrD,OAAO,QAAQ,aAAa,SAAS;CACvC;CAEA,QAAQ,WAAW;EACjB,MAAM,cAAc,CAAC,GAAG,OAAO,WAAW;EAC1C,MAAM,gBAAgB,eAAiC;GACrD,IAAI,CAAC,YAAY,MAAM,MAAM,EAAE,SAAS,WAAW,IAAI,GACrD,YAAY,KAAK,UAAU;EAE/B;EAEA,aAAa,wBAAwB,MAAM,QAAQ,CAAC;EACpD,aAAa,sBAAsB,MAAM,QAAQ,MAAM,SAAS,CAAC;EACjE,aAAa,yBAAyB,MAAM,WAAW,MAAM,KAAK,CAAC;EACnE,aAAa,wBAAwB,MAAM,UAAU,MAAM,MAAM,CAAC;EAClE,aAAa,6BAA6B,MAAM,aAAa,CAAC;EAC9D,IAAI,QAAQ,sBACV,aACE,6BAA6B,MAAM,eAAe,MAAM,SAAS,CACnE;EAGF,OAAO;GAAE,GAAG;GAAQ;EAAY;CAClC;AACF;;;ACzPA,SAAS,wBAAwB,OAAyB;CACxD,OACE,iBAAiB,SACjB,MAAM,QAAQ,SAAS,4BAA4B;AAEvD;AAEA,eAAe,eACb,KACA,SACA,OACA,OAC8C;CAO9C,QAAO,MADa,IAAI,KAAK,OAAO,EAAA,CACxB,MAAM,QAAQ,IAAI,WAAW,KAAK;AAChD;AAEA,eAAe,cACb,OACA,SACe;CACf,QAAQ,MAAM,MAAd;EACE,KAAK,mBAAmB;GACtB,MAAM,UAAU,MAAM,eACpB,QAAQ,UACR,QAAQ,SACR,sBACA,MAAM,kBACR;GACA,IAAI,SACF,MAAM,QAAQ,SAAS,OAAO,QAAQ,SAAS,QAAQ,IAAc,EACnE,QAAQ,MAAM,OAChB,CAAC;GAEH,MAAM,QAAQ,MAAM,eAClB,QAAQ,QACR,QAAQ,SACR,sBACA,MAAM,kBACR;GACA,IAAI,OAAO;IACT,MAAM,SACJ,MAAM,WAAW,cACb,SACA,MAAM,WAAW,aACf,aACA;IACR,MAAM,QAAQ,OAAO,OAAO,QAAQ,SAAS,MAAM,IAAc,EAC/D,OACF,CAAC;GACH;GACA;EACF;EACA,KAAK,iBAAiB;GACpB,MAAM,QAAQ,MAAM,eAClB,QAAQ,QACR,QAAQ,SACR,oBACA,MAAM,gBACR;GACA,IAAI,OACF,MAAM,QAAQ,OAAO,OAAO,QAAQ,SAAS,MAAM,IAAc,EAC/D,QAAQ,MAAM,OAChB,CAAC;GAEH;EACF;EACA,KAAK,wBAAwB;GAC3B,IAAI,CAAC,QAAQ,eAAe;GAC5B,MAAM,eAAe,MAAM,eACzB,QAAQ,eACR,QAAQ,SACR,2BACA,MAAM,uBACR;GACA,IAAI,cACF,MAAM,QAAQ,cAAc,OAC1B,QAAQ,SACR,aAAa,IACb,EAAE,QAAQ,MAAM,OAAO,CACzB;GAEF;EACF;EACA,KAAK,aACH;CACJ;AACF;;;;;;;;;;;;;AAcA,SAAgB,qBACd,SACA;CACA,OAAO,OAAO,MAAkC;EAI9C,MAAM,UAAU,MAAM,EAAE,IAAI,KAAK;EAQjC,IAAI,CAAC,MANkB,QAAQ,SAAS,uBAAuB;GAC7D;GACA,SAAS,EAAE,IAAI,IAAI;GACnB,QAAQ,QAAQ;GAChB,iBAAiB,QAAQ;EAC3B,CAAC,GAEC,OAAO,EAAE,KAAK,EAAE,OAAO,oBAAoB,GAAG,GAAG;EAGnD,MAAM,EAAE,SAAS,UAAU,QAAQ,SAAS,kBAAkB,OAAO;EAErE,IAAI;GACF,MAAM,QAAQ,cAAc,OAAO,QAAQ,SAAS;IAClD,UAAU,QAAQ,SAAS;IAC3B;IACA,WAAW,MAAM;GACnB,CAAC;EACH,SAAS,OAAO;GACd,IAAI,wBAAwB,KAAK,GAI/B,OAAO,EAAE,KAAK;IAAE,IAAI;IAAM,WAAW;GAAK,GAAG,GAAG;GAElD,MAAM;EACR;EAEA,IAAI;GACF,MAAM,cAAc,OAAO,OAAO;EACpC,SAAS,OAAO;GAKd,QAAQ,MAAM,qDAAqD,KAAK;EAC1E;EAEA,OAAO,EAAE,KAAK,EAAE,IAAI,KAAK,GAAG,GAAG;CACjC;AACF"}
1
+ {"version":3,"file":"index.js","names":["isUniqueConstraintError"],"sources":["../src/errors.ts","../src/checkout.ts","../src/collections.ts","../src/fulfillment.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 // Which FulfillmentProvider is shipping this order — independent of\n // `provider` (who charged the card); see types.ts's doc comment on\n // FulfillmentProvider for why these are separate axes.\n fulfillmentProvider: { type: \"text\" },\n // The fulfillment provider's own order identifier — the correlation\n // key createFulfillmentWebhookHandler dispatches inbound shipment\n // webhooks against, mirroring providerOrderRef's role for payments.\n fulfillmentProviderRef: { type: \"text\" },\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 {\n FulfillmentProvider,\n NormalizedFulfillmentWebhookEvent,\n} 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 CreateFulfillmentOrderOptions<TContext> {\n provider: FulfillmentProvider;\n orders: AnyLocalApi<TContext>;\n context: TContext;\n}\n\n/**\n * The order-paid hook implementation: submits an already-paid order's line\n * items to a `FulfillmentProvider` and persists the resulting\n * `fulfillmentProvider`/`fulfillmentProviderRef`/`fulfillmentStatus` back\n * onto the order row. Wire it as `WebhookHandlerOptions.onOrderPaid` on the\n * *payment* provider's webhook handler:\n *\n * ```ts\n * createWebhookHandler({\n * provider: stripeProvider,\n * orders, payments, webhookEvents, secret, context,\n * onOrderPaid: (order) =>\n * createFulfillmentOrder(order, { provider: printfulProvider, orders, context }),\n * });\n * ```\n *\n * Digital-goods-only stores simply never wire this — `fulfillmentProvider`\n * is plugin-optional, not a hard dependency of `ecommercePlugin`.\n */\nexport async function createFulfillmentOrder<TContext>(\n order: Record<string, unknown>,\n options: CreateFulfillmentOrderOptions<TContext>,\n): Promise<void> {\n const lineItems = (order.lineItems ?? []) as Array<{\n catalogRef?: string;\n quantity: number;\n }>;\n const shippingAddress =\n (order.shippingAddress as Record<string, string | undefined>) ?? {};\n\n const result = await options.provider.createFulfillmentOrder({\n orderId: order.id as number,\n lineItems: lineItems\n .filter((item): item is { catalogRef: string; quantity: number } =>\n Boolean(item.catalogRef),\n )\n .map((item) => ({\n catalogRef: item.catalogRef,\n quantity: item.quantity,\n })),\n shippingAddress,\n customerEmail: order.guestEmail as string | undefined,\n });\n\n await options.orders.update(options.context, order.id as number, {\n fulfillmentProvider: options.provider.name,\n fulfillmentProviderRef: result.providerFulfillmentRef,\n fulfillmentStatus: result.status,\n });\n}\n\nexport interface FulfillmentWebhookHandlerOptions<TContext> {\n provider: FulfillmentProvider;\n orders: AnyLocalApi<TContext>;\n webhookEvents: AnyLocalApi<TContext>;\n secret: string;\n /** See `WebhookHandlerOptions.context`'s identical note in webhook.ts. */\n context: TContext;\n}\n\nasync function findOrderByFulfillmentRef<TContext>(\n orders: AnyLocalApi<TContext>,\n context: TContext,\n providerFulfillmentRef: string,\n): Promise<Record<string, unknown> | undefined> {\n // Same in-memory-filter tradeoff as webhook.ts's findOneByField — revisit\n // with an indexed lookup only once volume makes it a measured problem.\n const rows = (await orders.find(context)) as Array<Record<string, unknown>>;\n return rows.find(\n (row) => row.fulfillmentProviderRef === providerFulfillmentRef,\n );\n}\n\nfunction isUniqueConstraintError(error: unknown): boolean {\n return (\n error instanceof Error &&\n error.message.includes(\"Unique constraint violated\")\n );\n}\n\nasync function dispatchFulfillmentEvent<TContext>(\n event: NormalizedFulfillmentWebhookEvent,\n options: FulfillmentWebhookHandlerOptions<TContext>,\n): Promise<void> {\n if (event.kind === \"unhandled\") return;\n\n const order = await findOrderByFulfillmentRef(\n options.orders,\n options.context,\n event.providerFulfillmentRef,\n );\n if (!order) return;\n\n await options.orders.update(options.context, order.id as number, {\n fulfillmentStatus: event.status,\n trackingNumber: event.trackingNumber,\n trackingCarrier: event.trackingCarrier,\n trackingUrl: event.trackingUrl,\n });\n}\n\n/**\n * Returns a Hono handler implementing inbound fulfillment-webhook handling\n * (shipment created/delivered/failed) against a `FulfillmentProvider` —\n * verify signature → dedup via `webhook_events` (shared with the payment\n * webhook handler; `eventId` is provider-specific so cross-provider\n * collisions aren't a concern) → look up the order by\n * `fulfillmentProviderRef` → update shipment status/tracking. Mirrors\n * `createWebhookHandler`'s structure exactly; kept as a separate function\n * rather than a generic merge of the two because the payment and\n * fulfillment event vocabularies, dedup keys, and target collections don't\n * overlap enough to share logic without branching on provider kind.\n */\nexport function createFulfillmentWebhookHandler<TContext>(\n options: FulfillmentWebhookHandlerOptions<TContext>,\n) {\n return async (c: Context): Promise<Response> => {\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 });\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 return c.json({ ok: true, duplicate: true }, 200);\n }\n throw error;\n }\n\n try {\n await dispatchFulfillmentEvent(event, options);\n } catch (error) {\n console.error(\n \"[cadmea-plugin-ecommerce] fulfillment webhook dispatch failed\",\n error,\n );\n }\n\n return c.json({ ok: true }, 200);\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 * Fires once, right after an order's status is written as \"paid\" by\n * this handler — the order-paid hook point a `FulfillmentProvider`\n * integration (e.g. `@thebes/cadmea-plugin-printful`) wires into to\n * submit the order for shipment. Receives the just-updated order row.\n * Errors thrown here are caught the same way dispatch-handler errors\n * are below — logged, never surfaced as a failure to the payment\n * provider, since the charge already succeeded and the provider must\n * not retry the whole webhook over a fulfillment-side problem.\n */\n onOrderPaid?: (order: Record<string, unknown>) => Promise<void>;\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 const updated = await options.orders.update(\n options.context,\n order.id as number,\n { status },\n );\n if (status === \"paid\" && options.onOrderPaid) {\n await options.onOrderPaid(updated);\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 const updated = await options.orders.update(\n options.context,\n order.id as number,\n { status: event.status },\n );\n if (event.status === \"paid\" && options.onOrderPaid) {\n await options.onOrderPaid(updated);\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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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thebes/cadmea-plugin-ecommerce",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Provider-agnostic ecommerce core for Cadmea — products/orders/customers/payments collections, the PaymentProvider interface, and checkout/webhook Hono handlers",
5
5
  "author": "BowenLabs <hello@bowenlabs.io>",
6
6
  "license": "MIT",