From 7b88229defb66c7fd02d669eb103435362cd53d8 Mon Sep 17 00:00:00 2001
From: almeida
Date: Sat, 13 Jun 2026 10:52:38 -0300
Subject: [PATCH 01/15] feat(web): add Dodo Payments page
---
apps/web/.source/browser.ts | 2 +-
apps/web/.source/server.ts | 70 +++---
apps/web/content/docs/basic-usage.mdx | 2 +-
.../docs/concepts/payment-providers.mdx | 13 ++
apps/web/content/docs/concepts/pix.mdx | 5 +
apps/web/content/docs/installation.mdx | 24 ++-
apps/web/content/docs/introduction.mdx | 6 +-
apps/web/content/docs/providers/dodo.mdx | 203 ++++++++++++++++++
apps/web/public/llms.txt | 26 ++-
apps/web/src/app/page.tsx | 8 +-
.../src/components/docs/mdx-components.tsx | 1 +
apps/web/src/lib/docs-navigation.ts | 4 +-
12 files changed, 314 insertions(+), 50 deletions(-)
create mode 100644 apps/web/content/docs/providers/dodo.mdx
diff --git a/apps/web/.source/browser.ts b/apps/web/.source/browser.ts
index 7cf14f5..ecc2455 100644
--- a/apps/web/.source/browser.ts
+++ b/apps/web/.source/browser.ts
@@ -7,6 +7,6 @@ const create = browser();
const browserCollections = {
- docs: create.doc("docs", {"basic-usage.mdx": () => import("../content/docs/basic-usage.mdx?collection=docs"), "comparison.mdx": () => import("../content/docs/comparison.mdx?collection=docs"), "installation.mdx": () => import("../content/docs/installation.mdx?collection=docs"), "introduction.mdx": () => import("../content/docs/introduction.mdx?collection=docs"), "mcp.mdx": () => import("../content/docs/mcp.mdx?collection=docs"), "adapters/elysia.mdx": () => import("../content/docs/adapters/elysia.mdx?collection=docs"), "adapters/express.mdx": () => import("../content/docs/adapters/express.mdx?collection=docs"), "adapters/fastify.mdx": () => import("../content/docs/adapters/fastify.mdx?collection=docs"), "adapters/hono.mdx": () => import("../content/docs/adapters/hono.mdx?collection=docs"), "adapters/next.mdx": () => import("../content/docs/adapters/next.mdx?collection=docs"), "ai-resources/skills.mdx": () => import("../content/docs/ai-resources/skills.mdx?collection=docs"), "database/drizzle.mdx": () => import("../content/docs/database/drizzle.mdx?collection=docs"), "database/overview.mdx": () => import("../content/docs/database/overview.mdx?collection=docs"), "database/postgres.mdx": () => import("../content/docs/database/postgres.mdx?collection=docs"), "database/prisma.mdx": () => import("../content/docs/database/prisma.mdx?collection=docs"), "concepts/api.mdx": () => import("../content/docs/concepts/api.mdx?collection=docs"), "concepts/client.mdx": () => import("../content/docs/concepts/client.mdx?collection=docs"), "concepts/customers.mdx": () => import("../content/docs/concepts/customers.mdx?collection=docs"), "concepts/hooks.mdx": () => import("../content/docs/concepts/hooks.mdx?collection=docs"), "concepts/payment-providers.mdx": () => import("../content/docs/concepts/payment-providers.mdx?collection=docs"), "concepts/pix.mdx": () => import("../content/docs/concepts/pix.mdx?collection=docs"), "concepts/sandbox.mdx": () => import("../content/docs/concepts/sandbox.mdx?collection=docs"), "concepts/typescript.mdx": () => import("../content/docs/concepts/typescript.mdx?collection=docs"), "guides/create-a-database-adapter.mdx": () => import("../content/docs/guides/create-a-database-adapter.mdx?collection=docs"), "guides/create-a-provider.mdx": () => import("../content/docs/guides/create-a-provider.mdx?collection=docs"), "guides/create-your-first-plugin.mdx": () => import("../content/docs/guides/create-your-first-plugin.mdx?collection=docs"), "guides/database.mdx": () => import("../content/docs/guides/database.mdx?collection=docs"), "guides/optimize-performance.mdx": () => import("../content/docs/guides/optimize-performance.mdx?collection=docs"), "guides/plugins.mdx": () => import("../content/docs/guides/plugins.mdx?collection=docs"), "guides/provider-selection.mdx": () => import("../content/docs/guides/provider-selection.mdx?collection=docs"), "guides/testing.mdx": () => import("../content/docs/guides/testing.mdx?collection=docs"), "guides/webhooks-at-scale.mdx": () => import("../content/docs/guides/webhooks-at-scale.mdx?collection=docs"), "guides/webhooks.mdx": () => import("../content/docs/guides/webhooks.mdx?collection=docs"), "plugins/audit-logs.mdx": () => import("../content/docs/plugins/audit-logs.mdx?collection=docs"), "plugins/dash.mdx": () => import("../content/docs/plugins/dash.mdx?collection=docs"), "plugins/overview.mdx": () => import("../content/docs/plugins/overview.mdx?collection=docs"), "providers/abacatepay.mdx": () => import("../content/docs/providers/abacatepay.mdx?collection=docs"), "providers/polar.mdx": () => import("../content/docs/providers/polar.mdx?collection=docs"), "providers/stripe.mdx": () => import("../content/docs/providers/stripe.mdx?collection=docs"), "reference/cli.mdx": () => import("../content/docs/reference/cli.mdx?collection=docs"), "reference/client-options.mdx": () => import("../content/docs/reference/client-options.mdx?collection=docs"), "reference/database-schema.mdx": () => import("../content/docs/reference/database-schema.mdx?collection=docs"), "reference/errors.mdx": () => import("../content/docs/reference/errors.mdx?collection=docs"), "reference/plugin-api.mdx": () => import("../content/docs/reference/plugin-api.mdx?collection=docs"), }),
+ docs: create.doc("docs", {"basic-usage.mdx": () => import("../content/docs/basic-usage.mdx?collection=docs"), "comparison.mdx": () => import("../content/docs/comparison.mdx?collection=docs"), "installation.mdx": () => import("../content/docs/installation.mdx?collection=docs"), "introduction.mdx": () => import("../content/docs/introduction.mdx?collection=docs"), "mcp.mdx": () => import("../content/docs/mcp.mdx?collection=docs"), "adapters/elysia.mdx": () => import("../content/docs/adapters/elysia.mdx?collection=docs"), "adapters/express.mdx": () => import("../content/docs/adapters/express.mdx?collection=docs"), "adapters/fastify.mdx": () => import("../content/docs/adapters/fastify.mdx?collection=docs"), "adapters/hono.mdx": () => import("../content/docs/adapters/hono.mdx?collection=docs"), "adapters/next.mdx": () => import("../content/docs/adapters/next.mdx?collection=docs"), "ai-resources/skills.mdx": () => import("../content/docs/ai-resources/skills.mdx?collection=docs"), "concepts/api.mdx": () => import("../content/docs/concepts/api.mdx?collection=docs"), "concepts/client.mdx": () => import("../content/docs/concepts/client.mdx?collection=docs"), "concepts/customers.mdx": () => import("../content/docs/concepts/customers.mdx?collection=docs"), "concepts/hooks.mdx": () => import("../content/docs/concepts/hooks.mdx?collection=docs"), "concepts/payment-providers.mdx": () => import("../content/docs/concepts/payment-providers.mdx?collection=docs"), "concepts/pix.mdx": () => import("../content/docs/concepts/pix.mdx?collection=docs"), "concepts/sandbox.mdx": () => import("../content/docs/concepts/sandbox.mdx?collection=docs"), "concepts/typescript.mdx": () => import("../content/docs/concepts/typescript.mdx?collection=docs"), "database/drizzle.mdx": () => import("../content/docs/database/drizzle.mdx?collection=docs"), "database/memory.mdx": () => import("../content/docs/database/memory.mdx?collection=docs"), "database/overview.mdx": () => import("../content/docs/database/overview.mdx?collection=docs"), "database/postgres.mdx": () => import("../content/docs/database/postgres.mdx?collection=docs"), "database/prisma.mdx": () => import("../content/docs/database/prisma.mdx?collection=docs"), "guides/create-a-database-adapter.mdx": () => import("../content/docs/guides/create-a-database-adapter.mdx?collection=docs"), "guides/create-a-provider.mdx": () => import("../content/docs/guides/create-a-provider.mdx?collection=docs"), "guides/create-your-first-plugin.mdx": () => import("../content/docs/guides/create-your-first-plugin.mdx?collection=docs"), "guides/database.mdx": () => import("../content/docs/guides/database.mdx?collection=docs"), "guides/optimize-performance.mdx": () => import("../content/docs/guides/optimize-performance.mdx?collection=docs"), "guides/plugins.mdx": () => import("../content/docs/guides/plugins.mdx?collection=docs"), "guides/provider-selection.mdx": () => import("../content/docs/guides/provider-selection.mdx?collection=docs"), "guides/testing.mdx": () => import("../content/docs/guides/testing.mdx?collection=docs"), "guides/webhooks-at-scale.mdx": () => import("../content/docs/guides/webhooks-at-scale.mdx?collection=docs"), "guides/webhooks.mdx": () => import("../content/docs/guides/webhooks.mdx?collection=docs"), "providers/abacatepay.mdx": () => import("../content/docs/providers/abacatepay.mdx?collection=docs"), "providers/dodo.mdx": () => import("../content/docs/providers/dodo.mdx?collection=docs"), "providers/polar.mdx": () => import("../content/docs/providers/polar.mdx?collection=docs"), "providers/stripe.mdx": () => import("../content/docs/providers/stripe.mdx?collection=docs"), "plugins/audit-logs.mdx": () => import("../content/docs/plugins/audit-logs.mdx?collection=docs"), "plugins/dash.mdx": () => import("../content/docs/plugins/dash.mdx?collection=docs"), "plugins/overview.mdx": () => import("../content/docs/plugins/overview.mdx?collection=docs"), "reference/cli.mdx": () => import("../content/docs/reference/cli.mdx?collection=docs"), "reference/client-options.mdx": () => import("../content/docs/reference/client-options.mdx?collection=docs"), "reference/database-schema.mdx": () => import("../content/docs/reference/database-schema.mdx?collection=docs"), "reference/errors.mdx": () => import("../content/docs/reference/errors.mdx?collection=docs"), "reference/plugin-api.mdx": () => import("../content/docs/reference/plugin-api.mdx?collection=docs"), }),
};
export default browserCollections;
\ No newline at end of file
diff --git a/apps/web/.source/server.ts b/apps/web/.source/server.ts
index 481ba5c..a5e160a 100644
--- a/apps/web/.source/server.ts
+++ b/apps/web/.source/server.ts
@@ -1,37 +1,39 @@
// @ts-nocheck
-import { frontmatter as __fd_glob_43 } from "../content/docs/reference/plugin-api.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_42 } from "../content/docs/reference/errors.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_41 } from "../content/docs/reference/database-schema.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_40 } from "../content/docs/reference/client-options.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_39 } from "../content/docs/reference/cli.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_38 } from "../content/docs/providers/stripe.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_37 } from "../content/docs/providers/polar.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_36 } from "../content/docs/providers/abacatepay.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_35 } from "../content/docs/plugins/overview.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_34 } from "../content/docs/plugins/dash.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_33 } from "../content/docs/plugins/audit-logs.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_32 } from "../content/docs/guides/webhooks.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_31 } from "../content/docs/guides/webhooks-at-scale.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_30 } from "../content/docs/guides/testing.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_29 } from "../content/docs/guides/provider-selection.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_28 } from "../content/docs/guides/plugins.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_27 } from "../content/docs/guides/optimize-performance.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_26 } from "../content/docs/guides/database.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_25 } from "../content/docs/guides/create-your-first-plugin.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_24 } from "../content/docs/guides/create-a-provider.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_23 } from "../content/docs/guides/create-a-database-adapter.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_22 } from "../content/docs/concepts/typescript.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_21 } from "../content/docs/concepts/sandbox.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_20 } from "../content/docs/concepts/pix.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_19 } from "../content/docs/concepts/payment-providers.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_18 } from "../content/docs/concepts/hooks.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_17 } from "../content/docs/concepts/customers.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_16 } from "../content/docs/concepts/client.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_15 } from "../content/docs/concepts/api.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_14 } from "../content/docs/database/prisma.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_13 } from "../content/docs/database/postgres.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_12 } from "../content/docs/database/overview.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_11 } from "../content/docs/database/drizzle.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_45 } from "../content/docs/reference/plugin-api.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_44 } from "../content/docs/reference/errors.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_43 } from "../content/docs/reference/database-schema.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_42 } from "../content/docs/reference/client-options.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_41 } from "../content/docs/reference/cli.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_40 } from "../content/docs/plugins/overview.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_39 } from "../content/docs/plugins/dash.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_38 } from "../content/docs/plugins/audit-logs.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_37 } from "../content/docs/providers/stripe.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_36 } from "../content/docs/providers/polar.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_35 } from "../content/docs/providers/dodo.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_34 } from "../content/docs/providers/abacatepay.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_33 } from "../content/docs/guides/webhooks.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_32 } from "../content/docs/guides/webhooks-at-scale.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_31 } from "../content/docs/guides/testing.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_30 } from "../content/docs/guides/provider-selection.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_29 } from "../content/docs/guides/plugins.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_28 } from "../content/docs/guides/optimize-performance.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_27 } from "../content/docs/guides/database.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_26 } from "../content/docs/guides/create-your-first-plugin.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_25 } from "../content/docs/guides/create-a-provider.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_24 } from "../content/docs/guides/create-a-database-adapter.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_23 } from "../content/docs/database/prisma.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_22 } from "../content/docs/database/postgres.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_21 } from "../content/docs/database/overview.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_20 } from "../content/docs/database/memory.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_19 } from "../content/docs/database/drizzle.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_18 } from "../content/docs/concepts/typescript.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_17 } from "../content/docs/concepts/sandbox.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_16 } from "../content/docs/concepts/pix.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_15 } from "../content/docs/concepts/payment-providers.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_14 } from "../content/docs/concepts/hooks.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_13 } from "../content/docs/concepts/customers.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_12 } from "../content/docs/concepts/client.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_11 } from "../content/docs/concepts/api.mdx?collection=docs&only=frontmatter"
import { frontmatter as __fd_glob_10 } from "../content/docs/ai-resources/skills.mdx?collection=docs&only=frontmatter"
import { frontmatter as __fd_glob_9 } from "../content/docs/adapters/next.mdx?collection=docs&only=frontmatter"
import { frontmatter as __fd_glob_8 } from "../content/docs/adapters/hono.mdx?collection=docs&only=frontmatter"
@@ -51,4 +53,4 @@ const create = server({"doc":{"passthroughs":["extractedReferences"]}});
-export const docs = await create.docsLazy("docs", "content/docs", {}, {"basic-usage.mdx": __fd_glob_0, "comparison.mdx": __fd_glob_1, "installation.mdx": __fd_glob_2, "introduction.mdx": __fd_glob_3, "mcp.mdx": __fd_glob_4, "adapters/elysia.mdx": __fd_glob_5, "adapters/express.mdx": __fd_glob_6, "adapters/fastify.mdx": __fd_glob_7, "adapters/hono.mdx": __fd_glob_8, "adapters/next.mdx": __fd_glob_9, "ai-resources/skills.mdx": __fd_glob_10, "database/drizzle.mdx": __fd_glob_11, "database/overview.mdx": __fd_glob_12, "database/postgres.mdx": __fd_glob_13, "database/prisma.mdx": __fd_glob_14, "concepts/api.mdx": __fd_glob_15, "concepts/client.mdx": __fd_glob_16, "concepts/customers.mdx": __fd_glob_17, "concepts/hooks.mdx": __fd_glob_18, "concepts/payment-providers.mdx": __fd_glob_19, "concepts/pix.mdx": __fd_glob_20, "concepts/sandbox.mdx": __fd_glob_21, "concepts/typescript.mdx": __fd_glob_22, "guides/create-a-database-adapter.mdx": __fd_glob_23, "guides/create-a-provider.mdx": __fd_glob_24, "guides/create-your-first-plugin.mdx": __fd_glob_25, "guides/database.mdx": __fd_glob_26, "guides/optimize-performance.mdx": __fd_glob_27, "guides/plugins.mdx": __fd_glob_28, "guides/provider-selection.mdx": __fd_glob_29, "guides/testing.mdx": __fd_glob_30, "guides/webhooks-at-scale.mdx": __fd_glob_31, "guides/webhooks.mdx": __fd_glob_32, "plugins/audit-logs.mdx": __fd_glob_33, "plugins/dash.mdx": __fd_glob_34, "plugins/overview.mdx": __fd_glob_35, "providers/abacatepay.mdx": __fd_glob_36, "providers/polar.mdx": __fd_glob_37, "providers/stripe.mdx": __fd_glob_38, "reference/cli.mdx": __fd_glob_39, "reference/client-options.mdx": __fd_glob_40, "reference/database-schema.mdx": __fd_glob_41, "reference/errors.mdx": __fd_glob_42, "reference/plugin-api.mdx": __fd_glob_43, }, {"basic-usage.mdx": () => import("../content/docs/basic-usage.mdx?collection=docs"), "comparison.mdx": () => import("../content/docs/comparison.mdx?collection=docs"), "installation.mdx": () => import("../content/docs/installation.mdx?collection=docs"), "introduction.mdx": () => import("../content/docs/introduction.mdx?collection=docs"), "mcp.mdx": () => import("../content/docs/mcp.mdx?collection=docs"), "adapters/elysia.mdx": () => import("../content/docs/adapters/elysia.mdx?collection=docs"), "adapters/express.mdx": () => import("../content/docs/adapters/express.mdx?collection=docs"), "adapters/fastify.mdx": () => import("../content/docs/adapters/fastify.mdx?collection=docs"), "adapters/hono.mdx": () => import("../content/docs/adapters/hono.mdx?collection=docs"), "adapters/next.mdx": () => import("../content/docs/adapters/next.mdx?collection=docs"), "ai-resources/skills.mdx": () => import("../content/docs/ai-resources/skills.mdx?collection=docs"), "database/drizzle.mdx": () => import("../content/docs/database/drizzle.mdx?collection=docs"), "database/overview.mdx": () => import("../content/docs/database/overview.mdx?collection=docs"), "database/postgres.mdx": () => import("../content/docs/database/postgres.mdx?collection=docs"), "database/prisma.mdx": () => import("../content/docs/database/prisma.mdx?collection=docs"), "concepts/api.mdx": () => import("../content/docs/concepts/api.mdx?collection=docs"), "concepts/client.mdx": () => import("../content/docs/concepts/client.mdx?collection=docs"), "concepts/customers.mdx": () => import("../content/docs/concepts/customers.mdx?collection=docs"), "concepts/hooks.mdx": () => import("../content/docs/concepts/hooks.mdx?collection=docs"), "concepts/payment-providers.mdx": () => import("../content/docs/concepts/payment-providers.mdx?collection=docs"), "concepts/pix.mdx": () => import("../content/docs/concepts/pix.mdx?collection=docs"), "concepts/sandbox.mdx": () => import("../content/docs/concepts/sandbox.mdx?collection=docs"), "concepts/typescript.mdx": () => import("../content/docs/concepts/typescript.mdx?collection=docs"), "guides/create-a-database-adapter.mdx": () => import("../content/docs/guides/create-a-database-adapter.mdx?collection=docs"), "guides/create-a-provider.mdx": () => import("../content/docs/guides/create-a-provider.mdx?collection=docs"), "guides/create-your-first-plugin.mdx": () => import("../content/docs/guides/create-your-first-plugin.mdx?collection=docs"), "guides/database.mdx": () => import("../content/docs/guides/database.mdx?collection=docs"), "guides/optimize-performance.mdx": () => import("../content/docs/guides/optimize-performance.mdx?collection=docs"), "guides/plugins.mdx": () => import("../content/docs/guides/plugins.mdx?collection=docs"), "guides/provider-selection.mdx": () => import("../content/docs/guides/provider-selection.mdx?collection=docs"), "guides/testing.mdx": () => import("../content/docs/guides/testing.mdx?collection=docs"), "guides/webhooks-at-scale.mdx": () => import("../content/docs/guides/webhooks-at-scale.mdx?collection=docs"), "guides/webhooks.mdx": () => import("../content/docs/guides/webhooks.mdx?collection=docs"), "plugins/audit-logs.mdx": () => import("../content/docs/plugins/audit-logs.mdx?collection=docs"), "plugins/dash.mdx": () => import("../content/docs/plugins/dash.mdx?collection=docs"), "plugins/overview.mdx": () => import("../content/docs/plugins/overview.mdx?collection=docs"), "providers/abacatepay.mdx": () => import("../content/docs/providers/abacatepay.mdx?collection=docs"), "providers/polar.mdx": () => import("../content/docs/providers/polar.mdx?collection=docs"), "providers/stripe.mdx": () => import("../content/docs/providers/stripe.mdx?collection=docs"), "reference/cli.mdx": () => import("../content/docs/reference/cli.mdx?collection=docs"), "reference/client-options.mdx": () => import("../content/docs/reference/client-options.mdx?collection=docs"), "reference/database-schema.mdx": () => import("../content/docs/reference/database-schema.mdx?collection=docs"), "reference/errors.mdx": () => import("../content/docs/reference/errors.mdx?collection=docs"), "reference/plugin-api.mdx": () => import("../content/docs/reference/plugin-api.mdx?collection=docs"), });
\ No newline at end of file
+export const docs = await create.docsLazy("docs", "content/docs", {}, {"basic-usage.mdx": __fd_glob_0, "comparison.mdx": __fd_glob_1, "installation.mdx": __fd_glob_2, "introduction.mdx": __fd_glob_3, "mcp.mdx": __fd_glob_4, "adapters/elysia.mdx": __fd_glob_5, "adapters/express.mdx": __fd_glob_6, "adapters/fastify.mdx": __fd_glob_7, "adapters/hono.mdx": __fd_glob_8, "adapters/next.mdx": __fd_glob_9, "ai-resources/skills.mdx": __fd_glob_10, "concepts/api.mdx": __fd_glob_11, "concepts/client.mdx": __fd_glob_12, "concepts/customers.mdx": __fd_glob_13, "concepts/hooks.mdx": __fd_glob_14, "concepts/payment-providers.mdx": __fd_glob_15, "concepts/pix.mdx": __fd_glob_16, "concepts/sandbox.mdx": __fd_glob_17, "concepts/typescript.mdx": __fd_glob_18, "database/drizzle.mdx": __fd_glob_19, "database/memory.mdx": __fd_glob_20, "database/overview.mdx": __fd_glob_21, "database/postgres.mdx": __fd_glob_22, "database/prisma.mdx": __fd_glob_23, "guides/create-a-database-adapter.mdx": __fd_glob_24, "guides/create-a-provider.mdx": __fd_glob_25, "guides/create-your-first-plugin.mdx": __fd_glob_26, "guides/database.mdx": __fd_glob_27, "guides/optimize-performance.mdx": __fd_glob_28, "guides/plugins.mdx": __fd_glob_29, "guides/provider-selection.mdx": __fd_glob_30, "guides/testing.mdx": __fd_glob_31, "guides/webhooks-at-scale.mdx": __fd_glob_32, "guides/webhooks.mdx": __fd_glob_33, "providers/abacatepay.mdx": __fd_glob_34, "providers/dodo.mdx": __fd_glob_35, "providers/polar.mdx": __fd_glob_36, "providers/stripe.mdx": __fd_glob_37, "plugins/audit-logs.mdx": __fd_glob_38, "plugins/dash.mdx": __fd_glob_39, "plugins/overview.mdx": __fd_glob_40, "reference/cli.mdx": __fd_glob_41, "reference/client-options.mdx": __fd_glob_42, "reference/database-schema.mdx": __fd_glob_43, "reference/errors.mdx": __fd_glob_44, "reference/plugin-api.mdx": __fd_glob_45, }, {"basic-usage.mdx": () => import("../content/docs/basic-usage.mdx?collection=docs"), "comparison.mdx": () => import("../content/docs/comparison.mdx?collection=docs"), "installation.mdx": () => import("../content/docs/installation.mdx?collection=docs"), "introduction.mdx": () => import("../content/docs/introduction.mdx?collection=docs"), "mcp.mdx": () => import("../content/docs/mcp.mdx?collection=docs"), "adapters/elysia.mdx": () => import("../content/docs/adapters/elysia.mdx?collection=docs"), "adapters/express.mdx": () => import("../content/docs/adapters/express.mdx?collection=docs"), "adapters/fastify.mdx": () => import("../content/docs/adapters/fastify.mdx?collection=docs"), "adapters/hono.mdx": () => import("../content/docs/adapters/hono.mdx?collection=docs"), "adapters/next.mdx": () => import("../content/docs/adapters/next.mdx?collection=docs"), "ai-resources/skills.mdx": () => import("../content/docs/ai-resources/skills.mdx?collection=docs"), "concepts/api.mdx": () => import("../content/docs/concepts/api.mdx?collection=docs"), "concepts/client.mdx": () => import("../content/docs/concepts/client.mdx?collection=docs"), "concepts/customers.mdx": () => import("../content/docs/concepts/customers.mdx?collection=docs"), "concepts/hooks.mdx": () => import("../content/docs/concepts/hooks.mdx?collection=docs"), "concepts/payment-providers.mdx": () => import("../content/docs/concepts/payment-providers.mdx?collection=docs"), "concepts/pix.mdx": () => import("../content/docs/concepts/pix.mdx?collection=docs"), "concepts/sandbox.mdx": () => import("../content/docs/concepts/sandbox.mdx?collection=docs"), "concepts/typescript.mdx": () => import("../content/docs/concepts/typescript.mdx?collection=docs"), "database/drizzle.mdx": () => import("../content/docs/database/drizzle.mdx?collection=docs"), "database/memory.mdx": () => import("../content/docs/database/memory.mdx?collection=docs"), "database/overview.mdx": () => import("../content/docs/database/overview.mdx?collection=docs"), "database/postgres.mdx": () => import("../content/docs/database/postgres.mdx?collection=docs"), "database/prisma.mdx": () => import("../content/docs/database/prisma.mdx?collection=docs"), "guides/create-a-database-adapter.mdx": () => import("../content/docs/guides/create-a-database-adapter.mdx?collection=docs"), "guides/create-a-provider.mdx": () => import("../content/docs/guides/create-a-provider.mdx?collection=docs"), "guides/create-your-first-plugin.mdx": () => import("../content/docs/guides/create-your-first-plugin.mdx?collection=docs"), "guides/database.mdx": () => import("../content/docs/guides/database.mdx?collection=docs"), "guides/optimize-performance.mdx": () => import("../content/docs/guides/optimize-performance.mdx?collection=docs"), "guides/plugins.mdx": () => import("../content/docs/guides/plugins.mdx?collection=docs"), "guides/provider-selection.mdx": () => import("../content/docs/guides/provider-selection.mdx?collection=docs"), "guides/testing.mdx": () => import("../content/docs/guides/testing.mdx?collection=docs"), "guides/webhooks-at-scale.mdx": () => import("../content/docs/guides/webhooks-at-scale.mdx?collection=docs"), "guides/webhooks.mdx": () => import("../content/docs/guides/webhooks.mdx?collection=docs"), "providers/abacatepay.mdx": () => import("../content/docs/providers/abacatepay.mdx?collection=docs"), "providers/dodo.mdx": () => import("../content/docs/providers/dodo.mdx?collection=docs"), "providers/polar.mdx": () => import("../content/docs/providers/polar.mdx?collection=docs"), "providers/stripe.mdx": () => import("../content/docs/providers/stripe.mdx?collection=docs"), "plugins/audit-logs.mdx": () => import("../content/docs/plugins/audit-logs.mdx?collection=docs"), "plugins/dash.mdx": () => import("../content/docs/plugins/dash.mdx?collection=docs"), "plugins/overview.mdx": () => import("../content/docs/plugins/overview.mdx?collection=docs"), "reference/cli.mdx": () => import("../content/docs/reference/cli.mdx?collection=docs"), "reference/client-options.mdx": () => import("../content/docs/reference/client-options.mdx?collection=docs"), "reference/database-schema.mdx": () => import("../content/docs/reference/database-schema.mdx?collection=docs"), "reference/errors.mdx": () => import("../content/docs/reference/errors.mdx?collection=docs"), "reference/plugin-api.mdx": () => import("../content/docs/reference/plugin-api.mdx?collection=docs"), });
\ No newline at end of file
diff --git a/apps/web/content/docs/basic-usage.mdx b/apps/web/content/docs/basic-usage.mdx
index e5ede22..3803aca 100644
--- a/apps/web/content/docs/basic-usage.mdx
+++ b/apps/web/content/docs/basic-usage.mdx
@@ -113,7 +113,7 @@ console.log(pix.expiresAt);
```
- In the current repository, native `pix` capability is implemented by `@paymesh/stripe` and `@paymesh/abacatepay`. The Polar provider intentionally does not advertise PIX support.
+ In the current repository, native `pix` capability is implemented by `@paymesh/stripe` and `@paymesh/abacatepay`. `@paymesh/polar` and `@paymesh/dodo` intentionally advertise `pix: false`. Dodo can still show Pix inside BRL hosted checkout links created through `paymesh.payments.create()`.
## Upsert and read a customer
diff --git a/apps/web/content/docs/concepts/payment-providers.mdx b/apps/web/content/docs/concepts/payment-providers.mdx
index 2a7591a..1727cec 100644
--- a/apps/web/content/docs/concepts/payment-providers.mdx
+++ b/apps/web/content/docs/concepts/payment-providers.mdx
@@ -54,6 +54,18 @@ AbacatePay currently implements:
AbacatePay does **not** expose subscriptions, refunds, or customer portal in the current repository.
+### Dodo
+
+Dodo currently implements:
+
+- hosted checkout payments through Dodo product carts
+- customers
+- catalog reads
+- subscription-aware dashboard sync
+- webhook verification and event mapping
+
+Dodo does **not** expose `paymesh.pix` in the current repository. BRL hosted checkouts can still present Pix inside the Dodo checkout page, but the provider does not currently return a native QR-code-first PIX object.
+
## Capability flags
Provider differences are carried through capability flags rather than hidden.
@@ -72,6 +84,7 @@ Some provider-specific differences still matter. For example:
- Polar requires `productIds` for checkout creation
- AbacatePay requires `productIds` for checkout creation and uses catalog-driven checkout
+- Dodo requires `productIds` for payment creation and can enable hosted Pix inside BRL checkouts
- Stripe PIX supports `amountIncludesIof` and expiration settings
- dashboard integrations vary by provider
diff --git a/apps/web/content/docs/concepts/pix.mdx b/apps/web/content/docs/concepts/pix.mdx
index 8072d0a..3c1cac5 100644
--- a/apps/web/content/docs/concepts/pix.mdx
+++ b/apps/web/content/docs/concepts/pix.mdx
@@ -64,12 +64,17 @@ In the current repository:
- `@paymesh/stripe` advertises `pix: true`
- `@paymesh/polar` advertises `pix: false`
- `@paymesh/abacatepay` advertises `pix: true`
+- `@paymesh/dodo` advertises `pix: false`
That means PIX-aware product code should either:
- choose a provider that exposes PIX
- or gate PIX UI behind provider capability detection
+
+ `@paymesh/dodo` can still surface Pix as a hosted payment method for BRL checkout links created through `paymesh.payments.create()`. It is not exposed through `paymesh.pix` because Dodo does not currently document a backend-native PIX flow that matches Paymesh's QR-code-first contract.
+
+
## Persistence model
With a database adapter attached, PIX state is stored separately in the built-in `pix` table. This is important because PIX-specific fields do not fit cleanly inside generic checkout rows.
diff --git a/apps/web/content/docs/installation.mdx b/apps/web/content/docs/installation.mdx
index 62a8805..4273b72 100644
--- a/apps/web/content/docs/installation.mdx
+++ b/apps/web/content/docs/installation.mdx
@@ -19,6 +19,10 @@ description: Install Paymesh, wire a provider, optionally add persistence, and m
+ If you need Dodo's catalog-driven hosted checkout flow:
+
+
+
You do not need a database on day one, but most real installations should add one quickly so webhook state, customers, PIX objects, and catalog snapshots are queryable locally.
@@ -29,7 +33,7 @@ description: Install Paymesh, wire a provider, optionally add persistence, and m
Put the client in a stable server-side module such as `src/lib/paymesh.ts`, `src/server/paymesh.ts`, or `app/lib/paymesh.ts`.
-
+
```ts title="src/lib/paymesh.ts"
import { createClient } from "paymesh";
@@ -69,7 +73,25 @@ description: Install Paymesh, wire a provider, optionally add persistence, and m
});
```
+
+ ```ts title="src/lib/paymesh.ts"
+ import { createClient } from "paymesh";
+ import { dodo } from "@paymesh/dodo";
+
+ export const paymesh = createClient({
+ provider: dodo({
+ apiKey: process.env.DODO_PAYMENTS_API_KEY!,
+ webhookSecret: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
+ baseUrl: "https://test.dodopayments.com",
+ }),
+ });
+ ```
+
+
+
+ `@paymesh/dodo` is catalog-driven like Polar and AbacatePay checkout flows. You create hosted payments with `productIds`. For BRL payments, the Dodo hosted checkout can offer Pix, but the provider does not expose `paymesh.pix`.
+
diff --git a/apps/web/content/docs/introduction.mdx b/apps/web/content/docs/introduction.mdx
index 94f54d2..b286bdb 100644
--- a/apps/web/content/docs/introduction.mdx
+++ b/apps/web/content/docs/introduction.mdx
@@ -25,7 +25,7 @@ Paymesh exists to normalize those concerns.
At runtime, Paymesh is built from four layers:
1. A `client` created with `createClient`.
-2. A `provider` such as `@paymesh/stripe` or `@paymesh/polar`.
+2. A `provider` such as `@paymesh/stripe`, `@paymesh/polar`, or `@paymesh/dodo`.
3. An optional `database` adapter such as `@paymesh/memory`, `@paymesh/postgres`, `@paymesh/drizzle`, or `@paymesh/prisma`.
4. Optional `plugins` such as `@paymesh/dash` and `@paymesh/audit-logs`.
@@ -75,14 +75,14 @@ The current repository exposes these first-party modules:
| Area | Package |
| --- | --- |
| Core client | `paymesh` |
-| Providers | `@paymesh/stripe`, `@paymesh/polar`, `@paymesh/abacatepay` |
+| Providers | `@paymesh/stripe`, `@paymesh/polar`, `@paymesh/abacatepay`, `@paymesh/dodo` |
| Database adapters | `@paymesh/memory`, `@paymesh/postgres`, `@paymesh/drizzle`, `@paymesh/prisma` |
| Framework adapters | `@paymesh/next`, `@paymesh/express`, `@paymesh/fastify`, `@paymesh/hono`, `@paymesh/elysia` |
| Plugins | `@paymesh/dash`, `@paymesh/audit-logs` |
| Tooling | `@paymesh/cli` |
- The docs below stay anchored to what is actually exported in the repository. Planned providers like PayPal and Dodo are treated as planned work, not presented like shipped integrations.
+ The docs below stay anchored to what is actually exported in the repository. PayPal remains planned work. Dodo is shipped as a catalog-driven hosted checkout provider with customer, subscription, catalog, and webhook support.
## Read this documentation like a system
diff --git a/apps/web/content/docs/providers/dodo.mdx b/apps/web/content/docs/providers/dodo.mdx
new file mode 100644
index 0000000..b620e19
--- /dev/null
+++ b/apps/web/content/docs/providers/dodo.mdx
@@ -0,0 +1,203 @@
+---
+title: Dodo
+description: "Detailed guide to the Dodo provider: setup options, catalog-driven hosted payments, customers, catalog reads, dashboard sync helpers, and webhook normalization."
+---
+
+## Overview
+
+`@paymesh/dodo` is the first-party Paymesh provider for Dodo Payments. It is the right choice when you want:
+
+- catalog-driven hosted checkout payments
+- normalized customer lifecycle methods
+- catalog reads from Dodo products
+- subscription-aware webhook mapping
+- dashboard sync helpers for customer, payment, and subscription state
+
+Unlike Stripe and AbacatePay, Dodo in this repository does **not** expose `paymesh.pix`. For BRL checkouts, the Dodo hosted checkout can still offer Pix inside the redirect flow.
+
+## Installation
+
+
+
+## Provider options
+
+| Option | Required | Description |
+| --- | --- | --- |
+| `apiKey` | yes in practice | Dodo API key used for authenticated requests. Defaults to `process.env.DODO_PAYMENTS_API_KEY`. |
+| `webhookSecret` | recommended | Secret used to verify `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers. Defaults to `process.env.DODO_PAYMENTS_WEBHOOK_KEY`. |
+| `sandbox` | no | Forces sandbox mode. Auto-detected from the base URL when omitted. |
+| `baseUrl` | no | Overrides the Dodo API base URL. Defaults to `https://live.dodopayments.com`. Use `https://test.dodopayments.com` for sandbox. |
+| `retry` | no | Retry policy forwarded to the shared request layer. |
+| `timeout` | no | Per-request timeout in milliseconds. |
+| `fetch` | no | Custom fetch implementation for tests or runtime-specific environments. |
+
+```ts title="src/lib/paymesh.ts"
+import { createClient } from "paymesh";
+import { dodo } from "@paymesh/dodo";
+
+export const paymesh = createClient({
+ provider: dodo({
+ apiKey: process.env.DODO_PAYMENTS_API_KEY!,
+ webhookSecret: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
+ baseUrl: "https://test.dodopayments.com",
+ }),
+});
+```
+
+## Capability profile
+
+| Capability | Supported |
+| --- | --- |
+| `checkout` | yes |
+| `pix` | no |
+| `customers` | yes |
+| `subscriptions` | yes |
+| `webhooks` | yes |
+| `refunds` | no |
+| `customerPortal` | no |
+| `coupons` | no |
+
+## Hosted checkout payments
+
+Dodo payment creation is catalog-driven. You must pass `productIds`.
+
+```ts title="src/server/payments.ts"
+const payment = await paymesh.payments.create({
+ amount: 4900,
+ currency: "BRL",
+ productIds: ["prod_abc123"],
+ customer: {
+ email: "billing@example.com",
+ externalId: "order_42",
+ name: "Billing Team",
+ },
+ successUrl: "http://localhost:3000/success",
+ cancelUrl: "http://localhost:3000/cancel",
+ metadata: {
+ orderId: "order_42",
+ },
+});
+```
+
+### Important constraints
+
+- `productIds` is required
+- `customer.id` or `customer.email` is required
+- `amount` can only be passed when exactly one product id is provided
+
+### Request mapping details
+
+- `productIds` maps to `product_cart`
+- `amount` maps into the product cart entry when one product id is present
+- `currency` maps to `billing_currency`
+- `customer.id` maps to `customer.customer_id`
+- `customer.email`, `customer.name`, and `customer.phone` map into the embedded customer payload when no `customer.id` exists
+- `metadata` values are serialized to strings before sending to Dodo
+- `successUrl`, `cancelUrl`, and `returnUrl` resolve into one `return_url`
+
+For BRL checkouts, the provider also enables Dodo's hosted Pix-capable payment method set automatically.
+
+## PIX note
+
+`@paymesh/dodo` does not implement `paymesh.pix`.
+
+That is deliberate. Dodo can present Pix inside the hosted checkout page for BRL payments, but the current Dodo API documentation does not describe a backend-native PIX flow that returns Paymesh's QR-code-first PIX fields such as `copyPasteCode` and `qrCodeImageUrlPng`.
+
+If your product needs native PIX payload fields, choose `@paymesh/stripe` or `@paymesh/abacatepay` instead.
+
+## Customer operations
+
+Dodo customer methods are normalized through `paymesh.customers`.
+
+```ts title="src/server/customers.ts"
+const customer = await paymesh.customers.upsert({
+ email: "billing@example.com",
+ externalId: "org_42",
+ name: "ACME Corp",
+ phone: "+55 11 99999-9999",
+ metadata: {
+ segment: "enterprise",
+ },
+});
+```
+
+### Customer mapping notes
+
+- `externalId` is stored in Dodo metadata as `externalId`
+- create uses `POST /customers`
+- update uses `PATCH /customers/:id`
+- delete is intentionally unsupported by this provider package
+
+If you attempt to create a customer without `email`, the provider throws `invalid_request`.
+
+## Catalog reads
+
+Dodo exposes `provider.catalog.list()` and reads products from `/products`.
+
+Paymesh normalizes:
+
+- products
+- one synthetic price entry per product when price data exists
+- recurring interval metadata when Dodo exposes recurring pricing fields
+
+```ts title="src/server/catalog.ts"
+const catalog = await paymesh.provider.catalog?.list();
+
+console.log(catalog?.products.length, catalog?.prices.length);
+```
+
+## Dashboard helpers
+
+Dodo ships `provider.dashboard` with:
+
+- customer sync
+- payment sync
+- subscription sync
+
+`getResourceUrl()` currently returns `null`, so operational dashboards should not assume Dodo deep links are available.
+
+## Webhook verification
+
+The provider verifies Dodo webhooks using:
+
+- `webhook-id`
+- `webhook-timestamp`
+- `webhook-signature`
+
+It rebuilds the signed message in the standard Dodo format and checks the `v1` HMAC-SHA256 signature against the configured webhook secret.
+
+If `webhookSecret` is missing, verification returns `false`.
+
+## Webhook normalization
+
+The provider currently normalizes these Dodo event families:
+
+| Dodo event | Paymesh event |
+| --- | --- |
+| `payment.succeeded` | `payment.succeeded` |
+| `payment.failed` | `payment.failed` |
+| `payment.processing` | `payment.created` |
+| `payment.cancelled` | `payment.canceled` |
+| `refund.succeeded` | `payment.refunded` |
+| `refund.failed` | `payment.failed` |
+| `subscription.active` | `subscription.created` |
+| `subscription.renewed` | `subscription.updated` |
+| `subscription.on_hold` | `subscription.updated` |
+| `subscription.paused` | `subscription.updated` |
+| `subscription.cancelled` | `subscription.canceled` |
+| `subscription.failed` | `subscription.updated` |
+| `subscription.expired` | `subscription.canceled` |
+| `subscription.plan_changed` | `subscription.updated` |
+| `subscription.updated` | `subscription.updated` |
+
+Pix-shaped Dodo payment payloads are still normalized as payments with `method: "pix"` when the webhook includes Pix-specific fields.
+
+## Choosing Dodo
+
+Use Dodo when:
+
+- your checkout flow is catalog-driven
+- you want Dodo Payments as the upstream billing system
+- you need hosted BRL checkout links that may offer Pix without building a native PIX flow
+
+Do not choose Dodo when your product requires `paymesh.pix` with QR code, copia-e-cola, or explicit PIX expiration fields as first-class backend data.
diff --git a/apps/web/public/llms.txt b/apps/web/public/llms.txt
index fb05132..6e07f04 100644
--- a/apps/web/public/llms.txt
+++ b/apps/web/public/llms.txt
@@ -9,7 +9,7 @@ For canonical documentation, prefer the docs pages linked below over inferring b
## Canonical project facts
- Core package: `paymesh`
-- Official live providers: `@paymesh/stripe`, `@paymesh/polar`, `@paymesh/abacatepay`
+- Official live providers: `@paymesh/stripe`, `@paymesh/polar`, `@paymesh/abacatepay`, `@paymesh/dodo`
- Official webhook adapters: `@paymesh/next`, `@paymesh/express`, `@paymesh/fastify`, `@paymesh/hono`, `@paymesh/elysia`
- Official database adapters: `@paymesh/postgres`, `@paymesh/drizzle`, `@paymesh/prisma`
- Official plugins: `@paymesh/dash`, `@paymesh/audit-logs`
@@ -166,12 +166,30 @@ Important limitation:
- `@paymesh/abacatepay` does not currently support subscriptions, refunds, or customer portal
+### Dodo
+
+Package: `@paymesh/dodo`
+
+Implemented capabilities:
+
+- checkout payments (catalog-driven with product IDs)
+- customers
+- webhooks
+- subscriptions
+- catalog reads
+- dashboard sync helpers for customers, payments, and subscriptions
+
+Important limitations:
+
+- `@paymesh/dodo` currently advertises `pix: false`
+- Dodo can expose Pix inside BRL hosted checkout, but not through `paymesh.pix`
+- customer delete is intentionally unsupported
+
### Planned providers
These appear in docs/navigation as planned work and should not be described as shipped integrations:
- PayPal
-- Dodo
## PIX
@@ -184,7 +202,7 @@ PIX is a first-class concept in Paymesh core, with normalized fields such as:
- `instructionsUrl`
- `expiresAt`
-PIX support is provider-specific. Stripe supports PIX. Polar currently does not. AbacatePay supports PIX through transparent charges.
+PIX support is provider-specific. Stripe supports PIX. Polar currently does not. AbacatePay supports PIX through transparent charges. Dodo can show hosted Pix for BRL checkouts but does not expose native `paymesh.pix`.
PIX create input supports:
@@ -475,7 +493,7 @@ Supported commands:
- Prefer the docs pages above when answering questions about Paymesh.
- Do not describe planned providers as implemented.
-- Treat Stripe, Polar, and AbacatePay as the official live providers.
+- Treat Stripe, Polar, AbacatePay, and Dodo as the official live providers.
- Treat PIX support as provider-specific, not universal.
- Mention raw-body requirements when discussing Express or Fastify webhooks.
- Mention that plugins are real runtime modules, not just helper functions.
diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx
index e2386d7..d6660e5 100644
--- a/apps/web/src/app/page.tsx
+++ b/apps/web/src/app/page.tsx
@@ -127,12 +127,12 @@ const providerCards: ProviderCard[] = [
{
id: '05',
name: 'Dodo Payments',
- status: 'coming soon',
- headline: 'Dodo is queued.',
+ status: 'supported',
+ headline: 'Dodo is live.',
description:
- 'Internet-native billing for teams that want optionality without integration churn.',
+ 'Catalog-driven hosted payments, customers, subscriptions, and webhook normalization through the same Paymesh client.',
icon: 'dodo',
- tags: ['global', 'merchant', 'tax'],
+ tags: ['catalog', 'hosted', 'webhooks'],
},
{
id: '06',
diff --git a/apps/web/src/components/docs/mdx-components.tsx b/apps/web/src/components/docs/mdx-components.tsx
index 585f993..344fdb7 100644
--- a/apps/web/src/components/docs/mdx-components.tsx
+++ b/apps/web/src/components/docs/mdx-components.tsx
@@ -12,6 +12,7 @@ const packageDocsLinks: Record = {
paymesh: '/docs/introduction',
'@paymesh/stripe': '/docs/providers/stripe',
'@paymesh/polar': '/docs/providers/polar',
+ '@paymesh/dodo': '/docs/providers/dodo',
'@paymesh/next': '/docs/adapters/next',
'@paymesh/express': '/docs/adapters/express',
'@paymesh/fastify': '/docs/adapters/fastify',
diff --git a/apps/web/src/lib/docs-navigation.ts b/apps/web/src/lib/docs-navigation.ts
index f0d8a05..f793647 100644
--- a/apps/web/src/lib/docs-navigation.ts
+++ b/apps/web/src/lib/docs-navigation.ts
@@ -114,8 +114,8 @@ export const docsNavigation: DocNavGroup[] = [
},
{
label: 'Dodo',
- description: 'Planned provider',
- status: 'planned',
+ href: '/docs/providers/dodo',
+ description: 'Catalog-driven Dodo Payments provider.',
icon: 'dodo',
},
],
From 080c26b2f7bb010548c3ad6109720fbd3b357d76 Mon Sep 17 00:00:00 2001
From: almeida
Date: Sat, 13 Jun 2026 10:53:12 -0300
Subject: [PATCH 02/15] feat(dodo): add new provider
---
packages/dodo/README.md | 69 ++++++
packages/dodo/package.json | 36 +++
packages/dodo/src/index.ts | 333 ++++++++++++++++++++++++++
packages/dodo/src/shared/constants.ts | 72 ++++++
packages/dodo/src/shared/mapper.ts | 176 ++++++++++++++
packages/dodo/src/shared/sync.ts | 115 +++++++++
packages/dodo/src/shared/utils.ts | 231 ++++++++++++++++++
packages/dodo/src/shared/webhooks.ts | 106 ++++++++
packages/dodo/src/types.ts | 219 +++++++++++++++++
packages/dodo/test/catalog.test.ts | 82 +++++++
packages/dodo/test/customers.test.ts | 179 ++++++++++++++
packages/dodo/test/dashboard.test.ts | 226 +++++++++++++++++
packages/dodo/test/dodo.test.ts | 250 +++++++++++++++++++
packages/dodo/test/webhooks.test.ts | 203 ++++++++++++++++
packages/dodo/tsconfig.json | 11 +
packages/dodo/tsdown.config.mjs | 12 +
16 files changed, 2320 insertions(+)
create mode 100644 packages/dodo/README.md
create mode 100644 packages/dodo/package.json
create mode 100644 packages/dodo/src/index.ts
create mode 100644 packages/dodo/src/shared/constants.ts
create mode 100644 packages/dodo/src/shared/mapper.ts
create mode 100644 packages/dodo/src/shared/sync.ts
create mode 100644 packages/dodo/src/shared/utils.ts
create mode 100644 packages/dodo/src/shared/webhooks.ts
create mode 100644 packages/dodo/src/types.ts
create mode 100644 packages/dodo/test/catalog.test.ts
create mode 100644 packages/dodo/test/customers.test.ts
create mode 100644 packages/dodo/test/dashboard.test.ts
create mode 100644 packages/dodo/test/dodo.test.ts
create mode 100644 packages/dodo/test/webhooks.test.ts
create mode 100644 packages/dodo/tsconfig.json
create mode 100644 packages/dodo/tsdown.config.mjs
diff --git a/packages/dodo/README.md b/packages/dodo/README.md
new file mode 100644
index 0000000..a8afa12
--- /dev/null
+++ b/packages/dodo/README.md
@@ -0,0 +1,69 @@
+
+
+@paymesh/dodo
+
+
+ Dodo Payments for Paymesh, designed for catalog-driven global checkout flows.
+
+
+
+ Launch Dodo hosted payments, manage customers, normalize webhooks, and keep the rest of your application on the standard Paymesh client contract.
+
+
+
+ Installation ·
+ Usage ·
+ What You Get ·
+ Important Notes
+
+
+
+
+Installation
+
+```bash
+npm install paymesh @paymesh/dodo
+```
+
+Usage
+
+```ts
+import { createClient } from "paymesh";
+import { dodo } from "@paymesh/dodo";
+
+const paymesh = createClient({
+ provider: dodo({
+ apiKey: process.env.DODO_PAYMENTS_API_KEY!,
+ webhookSecret: process.env.DODO_PAYMENTS_WEBHOOK_KEY!,
+ baseUrl: "https://test.dodopayments.com",
+ }),
+});
+
+const payment = await paymesh.payments.create({
+ productIds: ["prod_abc123"],
+ currency: "BRL",
+ customer: {
+ email: "ada@example.com",
+ name: "Ada Lovelace",
+ },
+ metadata: {
+ orderId: "order_123",
+ },
+ successUrl: "https://example.com/billing",
+ cancelUrl: "https://example.com/cancel",
+});
+
+console.log(payment.id, payment.checkoutUrl);
+```
+
+What You Get
+
+
+ @paymesh/dodo implements catalog-driven hosted payments, customer reads and upserts, catalog sync, dashboard sync helpers, webhook verification, and normalized Dodo event handling.
+
+
+Important Notes
+
+
+ Dodo can expose Pix as a hosted payment method for BRL checkouts, and this provider enables that automatically for BRL payment links. The current Paymesh pix contract is intentionally not exposed here because Dodo does not document a native backend Pix creation flow that matches Paymesh's QR-code-first shape.
+
diff --git a/packages/dodo/package.json b/packages/dodo/package.json
new file mode 100644
index 0000000..09cd2cf
--- /dev/null
+++ b/packages/dodo/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "@paymesh/dodo",
+ "version": "0.0.0",
+ "description": "Dodo Payments provider for Paymesh.",
+ "type": "module",
+ "license": "MIT",
+ "sideEffects": false,
+ "files": [
+ "dist"
+ ],
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/index.d.mts",
+ "default": "./dist/index.mjs"
+ },
+ "require": {
+ "types": "./dist/index.d.cts",
+ "default": "./dist/index.cjs"
+ }
+ }
+ },
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.mjs",
+ "types": "./dist/index.d.mts",
+ "scripts": {
+ "build": "tsdown",
+ "typecheck": "tsc --noEmit"
+ },
+ "peerDependencies": {
+ "paymesh": ">=0.0.0"
+ },
+ "devDependencies": {
+ "paymesh": "workspace:*"
+ }
+}
diff --git a/packages/dodo/src/index.ts b/packages/dodo/src/index.ts
new file mode 100644
index 0000000..f4df809
--- /dev/null
+++ b/packages/dodo/src/index.ts
@@ -0,0 +1,333 @@
+import {
+ type CustomerUpsertData,
+ defineProvider,
+ type PaymentCreateData,
+ PaymeshError,
+ type PaymeshEvent,
+ type ProviderRequestOptions,
+ type ProviderWebhookHandleOptions,
+ type ProviderWebhookHandleResult,
+ request,
+ withRaw,
+} from 'paymesh';
+import {
+ DODO_CAPABILITIES,
+ DODO_LIVE_BASE_URL,
+ DODO_PIX_METHOD_TYPES,
+ DODO_TEST_BASE_URL,
+} from './shared/constants';
+import {
+ mapDodoCatalogPrice,
+ mapDodoCatalogProduct,
+ mapDodoCustomer,
+ mapDodoPayment,
+} from './shared/mapper';
+import {
+ syncDodoCustomer,
+ syncDodoPayment,
+ syncDodoSubscription,
+} from './shared/sync';
+import {
+ buildDodoCustomerRequest,
+ buildDodoProductCart,
+ isRecord,
+ serializeMetadata,
+ verifyDodoWebhookSignature,
+} from './shared/utils';
+import {
+ resolveDodoWebhookData,
+ resolveDodoWebhookEventId,
+ resolveDodoWebhookHook,
+ resolveDodoWebhookType,
+} from './shared/webhooks';
+import type {
+ DodoCustomer,
+ DodoPaginatedResponse,
+ DodoPayment,
+ DodoProductListResponse,
+ DodoProviderOptions,
+ DodoWebhookPayload,
+} from './types';
+
+export type * from './types';
+
+export const dodo = ({
+ apiKey = process.env.DODO_PAYMENTS_API_KEY,
+ webhookSecret = process.env.DODO_PAYMENTS_WEBHOOK_KEY,
+ baseUrl = process.env.DODO_PAYMENTS_BASE_URL ?? DODO_LIVE_BASE_URL,
+ sandbox,
+ retry,
+ timeout,
+ fetch,
+}: DodoProviderOptions = {}) => {
+ const resolveProviderSandbox = () =>
+ typeof sandbox === 'boolean' ? sandbox : baseUrl === DODO_TEST_BASE_URL;
+
+ const headers = {
+ authorization: `Bearer ${apiKey}`,
+ 'content-type': 'application/json',
+ };
+
+ const baseRequestOptions = {
+ baseUrl,
+ fetch,
+ headers,
+ retry,
+ timeout,
+ };
+
+ const resolveRequestOptions = (
+ options?: ProviderRequestOptions,
+ ) => ({
+ baseUrl: options?.baseUrl ?? baseRequestOptions.baseUrl,
+ timeout: options?.timeout ?? baseRequestOptions.timeout,
+ retry: options?.retry ?? baseRequestOptions.retry,
+ fetch: options?.fetch ?? baseRequestOptions.fetch,
+ headers: baseRequestOptions.headers,
+ });
+
+ return defineProvider({
+ id: 'dodo',
+ isSandbox: resolveProviderSandbox,
+ capabilities: DODO_CAPABILITIES,
+ payments: {
+ async create(
+ data: PaymentCreateData,
+ options?: ProviderRequestOptions,
+ ) {
+ const customer = buildDodoCustomerRequest({
+ customer: data.customer,
+ provider: 'dodo',
+ });
+ const metadata = serializeMetadata(data.metadata);
+ const payment = await request('/payments', {
+ provider: 'dodo',
+ ...resolveRequestOptions(options),
+ method: 'POST',
+ body: {
+ billing: {
+ country: 'BR',
+ },
+ customer,
+ product_cart: buildDodoProductCart({
+ amount: data.amount,
+ productIds: data.productIds ?? [],
+ provider: 'dodo',
+ }),
+ allowed_payment_method_types:
+ data.currency?.toUpperCase() === 'BRL'
+ ? [...DODO_PIX_METHOD_TYPES]
+ : undefined,
+ billing_currency: data.currency?.toUpperCase(),
+ metadata,
+ payment_link: true,
+ return_url: data.returnUrl ?? data.successUrl ?? data.cancelUrl,
+ show_saved_payment_methods: Boolean(data.customer?.id),
+ },
+ });
+
+ const normalized = mapDodoPayment(payment, resolveProviderSandbox());
+ const result = {
+ id: normalized.id,
+ provider: normalized.provider,
+ sandbox: normalized.sandbox,
+ amount: normalized.amount,
+ currency: normalized.currency,
+ status: normalized.status,
+ checkoutUrl: normalized.checkoutUrl,
+ customer: normalized.customer,
+ metadata: normalized.metadata,
+ };
+
+ return withRaw(result, payment, options?.includeRaw);
+ },
+ },
+ customers: {
+ async get(
+ id: string,
+ options?: ProviderRequestOptions,
+ ) {
+ const customer = await request(
+ `/customers/${encodeURIComponent(id)}`,
+ {
+ provider: 'dodo',
+ ...resolveRequestOptions(options),
+ },
+ );
+
+ return withRaw(
+ mapDodoCustomer(customer, resolveProviderSandbox()),
+ customer,
+ options?.includeRaw,
+ );
+ },
+ async upsert(
+ data: CustomerUpsertData,
+ options?: ProviderRequestOptions,
+ ) {
+ if (!data.id && !data.email) {
+ throw new PaymeshError({
+ code: 'invalid_request',
+ message: 'Provider "dodo" requires "email" when creating customers',
+ provider: 'dodo',
+ });
+ }
+
+ const metadata = serializeMetadata({
+ ...data.metadata,
+ ...(data.externalId ? { externalId: data.externalId } : {}),
+ });
+
+ const customer = await request(
+ data.id ? `/customers/${encodeURIComponent(data.id)}` : '/customers',
+ {
+ provider: 'dodo',
+ ...resolveRequestOptions(options),
+ method: data.id ? 'PATCH' : 'POST',
+ body: data.id
+ ? {
+ email: data.email,
+ name: data.name,
+ phone_number: data.phone,
+ metadata,
+ }
+ : {
+ email: data.email,
+ name: data.name ?? data.email!,
+ phone_number: data.phone,
+ metadata,
+ },
+ },
+ );
+
+ return withRaw(
+ mapDodoCustomer(customer, resolveProviderSandbox()),
+ customer,
+ options?.includeRaw,
+ );
+ },
+ async delete(
+ id: string,
+ options?: ProviderRequestOptions,
+ ) {
+ throw new PaymeshError({
+ code: 'unsupported_capability',
+ message: 'Provider "dodo" does not support deleting customers.',
+ provider: 'dodo',
+ cause: { id, includeRaw: options?.includeRaw },
+ });
+ },
+ },
+ catalog: {
+ async list() {
+ const response = await request<
+ DodoPaginatedResponse
+ >('/products', {
+ provider: 'dodo',
+ ...baseRequestOptions,
+ query: {
+ page_size: 100,
+ },
+ });
+ const products = response.items ?? response.data ?? [];
+
+ return {
+ products: products.map((product) =>
+ mapDodoCatalogProduct(product, resolveProviderSandbox()),
+ ),
+ prices: products
+ .filter(
+ (product) =>
+ typeof product.price === 'number' ||
+ typeof product.price_detail?.price === 'number',
+ )
+ .map((product) =>
+ mapDodoCatalogPrice(product, resolveProviderSandbox()),
+ ),
+ };
+ },
+ },
+ dashboard: {
+ getResourceUrl() {
+ return null;
+ },
+ syncCustomer: (input) =>
+ syncDodoCustomer({
+ ...input,
+ requestOptions: baseRequestOptions,
+ sandbox: resolveProviderSandbox(),
+ }),
+ syncPayment: (input) =>
+ syncDodoPayment({
+ ...input,
+ requestOptions: baseRequestOptions,
+ sandbox: resolveProviderSandbox(),
+ }),
+ syncSubscription: (input) =>
+ syncDodoSubscription({
+ ...input,
+ requestOptions: baseRequestOptions,
+ sandbox: resolveProviderSandbox(),
+ }),
+ },
+ webhooks: {
+ async verify({ request }) {
+ const payload = await request.text();
+ return verifyDodoWebhookSignature({
+ headers: request.headers,
+ payload,
+ secret: webhookSecret,
+ });
+ },
+ async handle(
+ options: ProviderWebhookHandleOptions,
+ ): Promise> {
+ const { request: incoming, includeRaw = false } = options;
+ const payload = await incoming.text();
+
+ let event: DodoWebhookPayload;
+
+ try {
+ event = JSON.parse(payload) as DodoWebhookPayload;
+ } catch (error) {
+ throw new PaymeshError({
+ code: 'webhook_parse_error',
+ message: 'Dodo webhook payload must be valid JSON.',
+ provider: 'dodo',
+ cause: error,
+ });
+ }
+
+ if (!isRecord(event) || typeof event.type !== 'string') {
+ throw new TypeError('Dodo webhook payload must be a JSON object.');
+ }
+
+ const type = resolveDodoWebhookType(event.type);
+ const hook = resolveDodoWebhookHook(type);
+ const data = resolveDodoWebhookData(
+ event,
+ type,
+ includeRaw,
+ resolveProviderSandbox(),
+ );
+ const id = resolveDodoWebhookEventId(event);
+
+ return {
+ deliveryId: incoming.headers.get('webhook-id') ?? undefined,
+ hook,
+ event: withRaw(
+ {
+ id,
+ type,
+ provider: 'dodo',
+ sandbox: resolveProviderSandbox(),
+ data,
+ },
+ event,
+ includeRaw,
+ ) as PaymeshEvent,
+ };
+ },
+ },
+ });
+};
diff --git a/packages/dodo/src/shared/constants.ts b/packages/dodo/src/shared/constants.ts
new file mode 100644
index 0000000..7573a22
--- /dev/null
+++ b/packages/dodo/src/shared/constants.ts
@@ -0,0 +1,72 @@
+import type {
+ PaymentStatus,
+ PaymeshEventType,
+ ProviderCapabilities,
+} from 'paymesh';
+
+export const DODO_LIVE_BASE_URL = 'https://live.dodopayments.com';
+export const DODO_TEST_BASE_URL = 'https://test.dodopayments.com';
+
+export const DODO_CAPABILITIES = {
+ checkout: true,
+ pix: false,
+ coupons: false,
+ refunds: false,
+ subscriptions: true,
+ webhooks: true,
+ customerPortal: false,
+ customers: true,
+} satisfies ProviderCapabilities;
+
+export const DODO_PAYMENT_STATUSES: Record = {
+ succeeded: 'paid',
+ failed: 'failed',
+ cancelled: 'canceled',
+ processing: 'processing',
+ requires_customer_action: 'pending',
+ requires_merchant_action: 'pending',
+ requires_payment_method: 'failed',
+ requires_confirmation: 'pending',
+ requires_capture: 'processing',
+ partially_captured: 'processing',
+ partially_captured_and_capturable: 'processing',
+ pending: 'pending',
+ active: 'paid',
+ on_hold: 'processing',
+ expired: 'canceled',
+};
+
+export const DODO_EVENTS: Record = {
+ 'payment.succeeded': 'payment.succeeded',
+ 'payment.failed': 'payment.failed',
+ 'payment.processing': 'payment.created',
+ 'payment.cancelled': 'payment.canceled',
+ 'refund.succeeded': 'payment.refunded',
+ 'refund.failed': 'payment.failed',
+ 'subscription.active': 'subscription.created',
+ 'subscription.renewed': 'subscription.updated',
+ 'subscription.on_hold': 'subscription.updated',
+ 'subscription.paused': 'subscription.updated',
+ 'subscription.cancelled': 'subscription.canceled',
+ 'subscription.failed': 'subscription.updated',
+ 'subscription.expired': 'subscription.canceled',
+ 'subscription.plan_changed': 'subscription.updated',
+ 'subscription.updated': 'subscription.updated',
+} satisfies Record;
+
+export const DODO_HOOKS: Record = {
+ 'payment.created': 'onPaymentCreated',
+ 'payment.succeeded': 'onPaymentSucceeded',
+ 'payment.failed': 'onPaymentFailed',
+ 'payment.canceled': 'onPaymentCanceled',
+ 'payment.refunded': 'onPaymentRefunded',
+ 'customer.created': 'onCustomerCreated',
+ 'customer.updated': 'onCustomerUpdated',
+ 'customer.deleted': 'onCustomerDeleted',
+ 'subscription.created': 'onSubscriptionCreated',
+ 'subscription.updated': 'onSubscriptionUpdated',
+ 'subscription.canceled': 'onSubscriptionCanceled',
+ 'checkout.completed': 'onCheckoutCompleted',
+};
+
+export const DODO_PIX_METHOD_TYPES = ['pix', 'credit', 'debit'] as const;
diff --git a/packages/dodo/src/shared/mapper.ts b/packages/dodo/src/shared/mapper.ts
new file mode 100644
index 0000000..4d18c63
--- /dev/null
+++ b/packages/dodo/src/shared/mapper.ts
@@ -0,0 +1,176 @@
+import type { BaseCustomer, BasePayment, BasePix } from 'paymesh';
+import type {
+ DodoCustomer,
+ DodoPayment,
+ DodoProductListResponse,
+ DodoRefund,
+ DodoSubscription,
+} from '../types';
+import {
+ getDodoExternalId,
+ isDodoPixPayment,
+ mapDodoIntentStatus,
+ mapDodoRefundStatusToPaymentStatus,
+} from './utils';
+
+export function mapDodoCustomer(
+ customer: DodoCustomer,
+ sandbox: boolean,
+): BaseCustomer {
+ return {
+ id: customer.customer_id,
+ provider: 'dodo',
+ sandbox,
+ externalId: getDodoExternalId(customer.metadata),
+ name: customer.name,
+ email: customer.email,
+ phone: customer.phone_number ?? void 0,
+ metadata: customer.metadata ?? void 0,
+ };
+}
+
+export function mapDodoPayment(
+ payment: DodoPayment,
+ sandbox: boolean,
+): BasePayment | BasePix {
+ const customer = payment.customer
+ ? {
+ id: payment.customer.customer_id,
+ externalId: getDodoExternalId(payment.customer.metadata),
+ name: payment.customer?.name,
+ email: payment.customer.email,
+ phone: payment.customer.phone_number ?? void 0,
+ }
+ : void 0;
+
+ if (isDodoPixPayment(payment)) {
+ return {
+ id: payment.payment_id,
+ provider: 'dodo',
+ sandbox,
+ amount: payment.total_amount,
+ currency: payment.currency.toLowerCase(),
+ status: mapDodoIntentStatus(payment.status),
+ method: 'pix',
+ customer,
+ metadata: payment.metadata ?? void 0,
+ checkoutUrl: payment.payment_link ?? void 0,
+ };
+ }
+
+ return {
+ id: payment.payment_id,
+ provider: 'dodo',
+ sandbox,
+ amount: payment.total_amount,
+ currency: payment.currency.toLowerCase(),
+ status: mapDodoIntentStatus(payment.status),
+ checkoutUrl: payment.payment_link ?? void 0,
+ customer,
+ metadata: payment.metadata ?? void 0,
+ };
+}
+
+export function mapDodoRefundToPayment(
+ refund: DodoRefund,
+ sandbox: boolean,
+): BasePayment {
+ return {
+ id: refund.payment_id,
+ provider: 'dodo',
+ sandbox,
+ amount: refund.amount ?? 0,
+ currency: refund.currency?.toLowerCase() ?? 'usd',
+ status: mapDodoRefundStatusToPaymentStatus(refund.status),
+ customer: refund.customer
+ ? {
+ id: refund.customer.customer_id,
+ externalId: getDodoExternalId(refund.customer.metadata),
+ name: refund.customer.name,
+ email: refund.customer.email,
+ phone: refund.customer.phone_number ?? void 0,
+ }
+ : void 0,
+ metadata: {
+ ...(refund.metadata ?? {}),
+ refundId: refund.refund_id,
+ },
+ };
+}
+
+export function mapDodoSubscription(
+ subscription: DodoSubscription,
+ sandbox: boolean,
+) {
+ return {
+ id: subscription.subscription_id,
+ provider: 'dodo' as const,
+ sandbox,
+ customer: subscription.customer
+ ? {
+ id: subscription.customer.customer_id,
+ externalId: getDodoExternalId(subscription.customer.metadata),
+ name: subscription?.customer?.name,
+ email: subscription.customer.email,
+ phone: subscription.customer.phone_number ?? void 0,
+ }
+ : void 0,
+ productId: subscription.product_id,
+ amount: subscription.recurring_pre_tax_amount,
+ currency: subscription.currency.toLowerCase(),
+ status: subscription.status,
+ cancelAtPeriodEnd: subscription.cancel_at_next_billing_date,
+ metadata: subscription.metadata ?? void 0,
+ raw: subscription,
+ };
+}
+
+export function mapDodoCatalogProduct(
+ product: DodoProductListResponse,
+ sandbox: boolean,
+) {
+ return {
+ id: product.product_id,
+ sandbox,
+ name: product.name ?? void 0,
+ description: product.description ?? void 0,
+ active: true,
+ metadata: product.metadata ?? void 0,
+ version: product.metadata?.version ?? void 0,
+ raw: product,
+ };
+}
+
+export function mapDodoCatalogPrice(
+ product: DodoProductListResponse,
+ sandbox: boolean,
+) {
+ const price = product.price_detail;
+ const type =
+ price?.type === 'recurring_price'
+ ? 'recurring'
+ : price?.type === 'usage_based_price'
+ ? 'usage_based'
+ : 'one_time';
+
+ return {
+ id: `${product.product_id}_price`,
+ sandbox,
+ productId: product.product_id,
+ active: true,
+ type,
+ currency: (price?.currency ?? product.currency ?? void 0)?.toLowerCase(),
+ amount: price?.price ?? product.price ?? void 0,
+ interval:
+ price?.type === 'recurring_price' || price?.type === 'usage_based_price'
+ ? price.payment_frequency_interval?.toLowerCase()
+ : void 0,
+ intervalCount:
+ price?.type === 'recurring_price' || price?.type === 'usage_based_price'
+ ? price.payment_frequency_count
+ : void 0,
+ metadata: product.metadata ?? void 0,
+ version: product.metadata?.version ?? void 0,
+ raw: product,
+ };
+}
diff --git a/packages/dodo/src/shared/sync.ts b/packages/dodo/src/shared/sync.ts
new file mode 100644
index 0000000..0780930
--- /dev/null
+++ b/packages/dodo/src/shared/sync.ts
@@ -0,0 +1,115 @@
+import type { ProviderDashboardSyncInput } from 'paymesh';
+import { request, withRaw } from 'paymesh';
+import type { DodoCustomer, DodoPayment, DodoSubscription } from '../types';
+import { mapDodoCustomer, mapDodoPayment, mapDodoSubscription } from './mapper';
+
+type RequestOptions = Parameters[1];
+
+export const syncDodoCustomer = async ({
+ id,
+ schema,
+ database,
+ sandbox,
+ requestOptions,
+}: ProviderDashboardSyncInput & {
+ requestOptions?: RequestOptions;
+ sandbox: boolean;
+}) => {
+ const customer = await request(
+ `/customers/${encodeURIComponent(id)}`,
+ {
+ provider: 'dodo',
+ ...requestOptions,
+ },
+ );
+
+ const normalized = withRaw(
+ mapDodoCustomer(customer, sandbox),
+ customer,
+ true,
+ );
+
+ await database.repositories.customers.upsert(schema, normalized);
+
+ return normalized;
+};
+
+export const syncDodoPayment = async ({
+ id,
+ schema,
+ database,
+ sandbox,
+ requestOptions,
+}: ProviderDashboardSyncInput & {
+ requestOptions?: RequestOptions;
+ sandbox: boolean;
+}) => {
+ const payment = await request(
+ `/payments/${encodeURIComponent(id)}`,
+ {
+ provider: 'dodo',
+ ...requestOptions,
+ },
+ );
+ const normalizedPayment = mapDodoPayment(payment, sandbox);
+ const normalized = withRaw(normalizedPayment, payment, true);
+
+ const promises = [database.repositories.invoices.upsert(schema, normalized)];
+
+ if ('method' in normalizedPayment && normalizedPayment.method === 'pix')
+ promises.push(
+ database.repositories.pix.upsert(
+ schema,
+ normalized as Extract,
+ ),
+ );
+
+ await Promise.all(promises);
+
+ return normalized;
+};
+
+export const syncDodoSubscription = async ({
+ id,
+ schema,
+ database,
+ sandbox,
+ requestOptions,
+}: ProviderDashboardSyncInput & {
+ requestOptions?: RequestOptions;
+ sandbox: boolean;
+}) => {
+ const subscription = await request(
+ `/subscriptions/${encodeURIComponent(id)}`,
+ {
+ provider: 'dodo',
+ ...requestOptions,
+ },
+ );
+ const type =
+ subscription.status === 'cancelled' || subscription.status === 'expired'
+ ? ('subscription.canceled' as const)
+ : ('subscription.updated' as const);
+
+ const event = withRaw(
+ {
+ id,
+ type,
+ sandbox,
+ provider: 'dodo',
+ data: withRaw(
+ {
+ ...mapDodoSubscription(subscription, sandbox),
+ },
+ subscription,
+ true,
+ ),
+ },
+ subscription,
+ true,
+ );
+
+ await database.repositories.subscriptions.upsert(schema, event);
+
+ return event.data as unknown as Record;
+};
diff --git a/packages/dodo/src/shared/utils.ts b/packages/dodo/src/shared/utils.ts
new file mode 100644
index 0000000..1763b67
--- /dev/null
+++ b/packages/dodo/src/shared/utils.ts
@@ -0,0 +1,231 @@
+import { createHmac, timingSafeEqual } from 'node:crypto';
+import { PaymeshError } from 'paymesh';
+import type {
+ DodoIntentStatus,
+ DodoMetadata,
+ DodoPayment,
+ DodoRefund,
+ DodoSubscription,
+} from '../types';
+import { DODO_PAYMENT_STATUSES } from './constants';
+
+export function getDodoExternalId(metadata?: DodoMetadata | null) {
+ return typeof metadata?.externalId === 'string' &&
+ metadata.externalId.length > 0
+ ? metadata.externalId
+ : undefined;
+}
+
+export function serializeMetadata(
+ metadata?: Record,
+) {
+ const entries = Object.entries(metadata ?? {}).filter(
+ ([, value]) => value !== null,
+ );
+ if (entries.length === 0) return undefined;
+
+ return Object.fromEntries(
+ entries.map(([key, value]) => [key, String(value)]),
+ ) as Record;
+}
+
+export function buildDodoCustomerRequest(input?: {
+ customer?: {
+ email?: string;
+ id?: string;
+ name?: string;
+ phone?: string;
+ };
+ provider?: string;
+}) {
+ const customer = input?.customer;
+
+ if (!customer?.id && !customer?.email) {
+ throw new PaymeshError({
+ code: 'invalid_request',
+ message:
+ 'Provider "dodo" requires either "customer.id" or "customer.email" when creating payments.',
+ provider: input?.provider ?? 'dodo',
+ });
+ }
+
+ if (customer.id) {
+ return { customer_id: customer.id };
+ }
+
+ return {
+ email: customer.email!,
+ name: customer.name ?? customer.email!,
+ phone_number: customer.phone,
+ };
+}
+
+export function isDodoPixPayment(
+ payment:
+ | DodoPayment
+ | Pick
+ | {
+ currency?: string | null;
+ payment_method?: string | null;
+ payment_method_type?: string | null;
+ },
+) {
+ return (
+ 'payment_method_type' in payment &&
+ (payment.payment_method_type === 'pix' ||
+ payment.payment_method === 'real_time_payment')
+ );
+}
+
+export function mapDodoIntentStatus(status?: DodoIntentStatus | null) {
+ return !status ? 'pending' : (DODO_PAYMENT_STATUSES[status] ?? 'pending');
+}
+
+export function parseDodoWebhookSecret(secret: string) {
+ const value = secret.startsWith('whsec_')
+ ? secret.slice('whsec_'.length)
+ : secret;
+ return Buffer.from(value, 'base64');
+}
+
+export function signDodoWebhook(options: {
+ payload: string;
+ secret: string;
+ timestamp: string;
+ webhookId: string;
+}) {
+ const body = `${options.webhookId}.${options.timestamp}.${options.payload}`;
+ const expected = createHmac('sha256', parseDodoWebhookSecret(options.secret))
+ .update(body)
+ .digest('base64');
+
+ return `v1,${expected}`;
+}
+
+export function verifyDodoWebhookSignature(options: {
+ headers: Headers;
+ payload: string;
+ secret?: string;
+}) {
+ if (!options.secret) return false;
+
+ const webhookId = options.headers.get('webhook-id');
+ const timestamp = options.headers.get('webhook-timestamp');
+ const signatureHeader = options.headers.get('webhook-signature');
+
+ if (!webhookId || !timestamp || !signatureHeader) return false;
+
+ const timestampValue = Number.parseInt(timestamp, 10);
+ if (!Number.isFinite(timestampValue)) return false;
+
+ const now = Math.floor(Date.now() / 1000);
+ if (Math.abs(now - timestampValue) > 5 * 60) return false;
+
+ const expected = signDodoWebhook({
+ payload: options.payload,
+ secret: options.secret,
+ timestamp,
+ webhookId,
+ });
+
+ const expectedSignature = expected.split(',')[1];
+ if (!expectedSignature) return false;
+
+ for (const part of signatureHeader.split(/\s+/)) {
+ const [version, signature] = part.split(',');
+ if (version !== 'v1' || !signature) continue;
+ if (
+ signature.length === expectedSignature.length &&
+ timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+export function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null;
+}
+
+export function resolveDodoSubscriptionEventType(
+ subscription: DodoSubscription,
+) {
+ if (
+ subscription.status === 'cancelled' ||
+ subscription.status === 'expired'
+ ) {
+ return 'subscription.canceled' as const;
+ }
+
+ return 'subscription.updated' as const;
+}
+
+export function ensureDodoProductIds(productIds?: string[]) {
+ if (!productIds || productIds.length === 0) {
+ throw new PaymeshError({
+ code: 'invalid_request',
+ message:
+ 'Provider "dodo" requires at least one product id in "productIds"',
+ provider: 'dodo',
+ });
+ }
+}
+
+export function buildDodoProductCart(input: {
+ amount?: number;
+ productIds: string[];
+ provider?: string;
+}) {
+ ensureDodoProductIds(input.productIds);
+
+ if (typeof input.amount === 'number' && input.productIds.length > 1)
+ throw new PaymeshError({
+ code: 'invalid_request',
+ message:
+ 'Provider "dodo" only accepts "amount" when exactly one product id is provided.',
+ provider: input.provider ?? 'dodo',
+ });
+
+ return input.productIds.map((productId) => ({
+ product_id: productId,
+ quantity: 1,
+ amount: typeof input.amount === 'number' ? input.amount : undefined,
+ }));
+}
+
+export function resolveDodoCustomerFromInput(input?: {
+ email?: string;
+ externalId?: string;
+ id?: string;
+ name?: string;
+ phone?: string;
+}) {
+ if (!input) return undefined;
+
+ if (
+ !input.id &&
+ !input.email &&
+ !input.externalId &&
+ !input.name &&
+ !input.phone
+ ) {
+ return undefined;
+ }
+
+ return {
+ id: input.id,
+ externalId: input.externalId,
+ name: input.name,
+ email: input.email,
+ phone: input.phone,
+ };
+}
+
+export function mapDodoRefundStatusToPaymentStatus(status?: string | null) {
+ if (status === 'succeeded') return 'refunded' as const;
+ if (status === 'failed') return 'failed' as const;
+ if (status === 'review') return 'processing' as const;
+ return 'pending' as const;
+}
diff --git a/packages/dodo/src/shared/webhooks.ts b/packages/dodo/src/shared/webhooks.ts
new file mode 100644
index 0000000..74987e8
--- /dev/null
+++ b/packages/dodo/src/shared/webhooks.ts
@@ -0,0 +1,106 @@
+import type { PaymeshEventType } from 'paymesh';
+import { withRaw } from 'paymesh';
+import type { DodoWebhookPayload } from '../types';
+import { DODO_EVENTS, DODO_HOOKS } from './constants';
+import {
+ mapDodoPayment,
+ mapDodoRefundToPayment,
+ mapDodoSubscription,
+} from './mapper';
+
+function isDodoPaymentWebhook(event: DodoWebhookPayload): event is Extract<
+ DodoWebhookPayload,
+ {
+ type:
+ | 'payment.succeeded'
+ | 'payment.failed'
+ | 'payment.processing'
+ | 'payment.cancelled';
+ }
+> {
+ return (
+ event.type === 'payment.succeeded' ||
+ event.type === 'payment.failed' ||
+ event.type === 'payment.processing' ||
+ event.type === 'payment.cancelled'
+ );
+}
+
+function isDodoRefundWebhook(
+ event: DodoWebhookPayload,
+): event is Extract<
+ DodoWebhookPayload,
+ { type: 'refund.succeeded' | 'refund.failed' }
+> {
+ return event.type === 'refund.succeeded' || event.type === 'refund.failed';
+}
+
+function isDodoSubscriptionWebhook(event: DodoWebhookPayload): event is Extract<
+ DodoWebhookPayload,
+ {
+ type:
+ | 'subscription.active'
+ | 'subscription.renewed'
+ | 'subscription.on_hold'
+ | 'subscription.paused'
+ | 'subscription.cancelled'
+ | 'subscription.failed'
+ | 'subscription.expired'
+ | 'subscription.plan_changed'
+ | 'subscription.updated';
+ }
+> {
+ return event.type.startsWith('subscription.');
+}
+
+export function resolveDodoWebhookType(type: string): PaymeshEventType {
+ return DODO_EVENTS[type] ?? 'payment.created';
+}
+
+export function resolveDodoWebhookHook(type: PaymeshEventType) {
+ return DODO_HOOKS[type];
+}
+
+export function resolveDodoWebhookData(
+ event: DodoWebhookPayload,
+ _type: PaymeshEventType,
+ includeRaw: boolean,
+ sandbox: boolean,
+) {
+ if (isDodoPaymentWebhook(event)) {
+ return withRaw(mapDodoPayment(event.data, sandbox), event.data, includeRaw);
+ }
+
+ if (isDodoRefundWebhook(event)) {
+ return withRaw(
+ mapDodoRefundToPayment(event.data, sandbox),
+ event.data,
+ includeRaw,
+ );
+ }
+
+ if (isDodoSubscriptionWebhook(event)) {
+ return withRaw(
+ mapDodoSubscription(event.data, sandbox),
+ event.data,
+ includeRaw,
+ );
+ }
+
+ return withRaw(
+ {
+ ...event.data,
+ provider: 'dodo',
+ sandbox,
+ },
+ event.data,
+ includeRaw,
+ );
+}
+
+export function resolveDodoWebhookEventId(event: DodoWebhookPayload) {
+ if (isDodoPaymentWebhook(event)) return event.data.payment_id;
+ if (isDodoRefundWebhook(event)) return event.data.refund_id;
+ if (isDodoSubscriptionWebhook(event)) return event.data.subscription_id;
+ return `${event.type}:${event.timestamp}`;
+}
diff --git a/packages/dodo/src/types.ts b/packages/dodo/src/types.ts
new file mode 100644
index 0000000..ebacef4
--- /dev/null
+++ b/packages/dodo/src/types.ts
@@ -0,0 +1,219 @@
+import type { RetryOptions } from 'paymesh';
+
+export interface DodoProviderOptions {
+ apiKey?: string;
+ sandbox?: boolean;
+ webhookSecret?: string;
+ baseUrl?: string;
+ retry?: RetryOptions;
+ timeout?: number;
+ fetch?: typeof fetch;
+}
+
+export type DodoMetadata = Record | null;
+
+export interface DodoCustomer {
+ business_id?: string;
+ created_at?: string;
+ customer_id: string;
+ email: string;
+ name: string;
+ metadata?: DodoMetadata;
+ phone_number?: string | null;
+}
+
+export type DodoIntentStatus =
+ | 'succeeded'
+ | 'failed'
+ | 'cancelled'
+ | 'processing'
+ | 'requires_customer_action'
+ | 'requires_merchant_action'
+ | 'requires_payment_method'
+ | 'requires_confirmation'
+ | 'requires_capture'
+ | 'partially_captured'
+ | 'partially_captured_and_capturable';
+
+export interface DodoPayment {
+ billing?: {
+ country?: string;
+ city?: string | null;
+ state?: string | null;
+ street?: string | null;
+ zipcode?: string | null;
+ };
+ brand_id?: string;
+ business_id?: string;
+ created_at?: string;
+ currency: string;
+ customer: DodoCustomer;
+ digital_products_delivered?: boolean;
+ disputes?: unknown[];
+ metadata: DodoMetadata;
+ payment_id: string;
+ payment_provider?: 'stripe' | 'adyen' | 'dodo';
+ refunds?: DodoRefund[];
+ retry_attempt?: number;
+ settlement_amount?: number;
+ settlement_currency?: string;
+ total_amount: number;
+ card_holder_name?: string | null;
+ card_issuing_country?: string | null;
+ card_last_four?: string | null;
+ card_network?: string | null;
+ card_type?: string | null;
+ checkout_session_id?: string | null;
+ custom_field_responses?: Array<{ key: string; value: string }> | null;
+ discount_id?: string | null;
+ discounts?: unknown[] | null;
+ error_code?: string | null;
+ error_message?: string | null;
+ invoice_id?: string | null;
+ invoice_url?: string | null;
+ payment_link?: string | null;
+ payment_method?: string | null;
+ payment_method_type?: string | null;
+ product_cart?: Array<{
+ product_id: string;
+ quantity: number;
+ amount?: number | null;
+ }> | null;
+ refund_status?: string | null;
+ status?: DodoIntentStatus | null;
+ subscription_id?: string | null;
+}
+
+export interface DodoRefund {
+ amount?: number | null;
+ business_id?: string;
+ created_at?: string;
+ customer?: DodoCustomer;
+ is_partial?: boolean;
+ metadata?: DodoMetadata;
+ payment_id: string;
+ refund_id: string;
+ status: 'succeeded' | 'failed' | 'pending' | 'review';
+ currency?: string | null;
+ reason?: string | null;
+}
+
+export interface DodoSubscription {
+ addons?: Array<{
+ addon_id: string;
+ name?: string;
+ quantity: number;
+ }>;
+ billing?: DodoPayment['billing'];
+ cancel_at_next_billing_date: boolean;
+ created_at?: string;
+ currency: string;
+ customer: DodoCustomer;
+ metadata: DodoMetadata;
+ next_billing_date?: string;
+ on_demand?: boolean;
+ payment_frequency_count?: number;
+ payment_frequency_interval?: string;
+ previous_billing_date?: string;
+ product_id: string;
+ quantity: number;
+ recurring_pre_tax_amount: number;
+ status: 'pending' | 'active' | 'on_hold' | 'cancelled' | 'failed' | 'expired';
+ subscription_id: string;
+ subscription_period_count?: number;
+ subscription_period_interval?: string;
+ tax_inclusive?: boolean;
+ trial_period_days?: number;
+ cancellation_comment?: string | null;
+ cancellation_feedback?: string | null;
+ cancelled_at?: string | null;
+ custom_field_responses?: Array<{ key: string; value: string }> | null;
+ customer_business_name?: string | null;
+ discount_id?: string | null;
+ discounts?: unknown[] | null;
+ expires_at?: string | null;
+ payment_method_id?: string | null;
+ scheduled_change?: unknown | null;
+ tax_id?: string | null;
+}
+
+export interface DodoCheckoutResponse {
+ session_id: string;
+ checkout_url?: string | null;
+ client_secret?: string | null;
+ payment_id?: string | null;
+ publishable_key?: string | null;
+}
+
+export interface DodoProductPrice {
+ type: 'one_time_price' | 'recurring_price' | 'usage_based_price';
+ currency: string;
+ price: number;
+ payment_frequency_count?: number;
+ payment_frequency_interval?: 'Day' | 'Week' | 'Month' | 'Year';
+ subscription_period_count?: number;
+ subscription_period_interval?: 'Day' | 'Week' | 'Month' | 'Year';
+ tax_inclusive?: boolean | null;
+}
+
+export interface DodoProductListResponse {
+ business_id?: string;
+ created_at?: string;
+ entitlements?: unknown[];
+ is_recurring: boolean;
+ metadata: DodoMetadata;
+ product_id: string;
+ tax_category?: string;
+ updated_at?: string;
+ currency?: string | null;
+ description?: string | null;
+ image?: string | null;
+ name?: string | null;
+ price?: number | null;
+ price_detail?: DodoProductPrice | null;
+ tax_inclusive?: boolean | null;
+}
+
+export interface DodoPaginatedResponse {
+ items?: T[];
+ data?: T[];
+}
+
+export type DodoWebhookPayload =
+ | {
+ business_id: string;
+ data: DodoPayment;
+ timestamp: string;
+ type:
+ | 'payment.succeeded'
+ | 'payment.failed'
+ | 'payment.processing'
+ | 'payment.cancelled';
+ }
+ | {
+ business_id: string;
+ data: DodoRefund;
+ timestamp: string;
+ type: 'refund.succeeded' | 'refund.failed';
+ }
+ | {
+ business_id: string;
+ data: DodoSubscription;
+ timestamp: string;
+ type:
+ | 'subscription.active'
+ | 'subscription.renewed'
+ | 'subscription.on_hold'
+ | 'subscription.paused'
+ | 'subscription.cancelled'
+ | 'subscription.failed'
+ | 'subscription.expired'
+ | 'subscription.plan_changed'
+ | 'subscription.updated';
+ }
+ | {
+ business_id?: string;
+ data: Record;
+ timestamp: string;
+ type: string;
+ };
diff --git a/packages/dodo/test/catalog.test.ts b/packages/dodo/test/catalog.test.ts
new file mode 100644
index 0000000..65393a0
--- /dev/null
+++ b/packages/dodo/test/catalog.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, test } from 'bun:test';
+import { dodo } from '../src';
+
+describe('dodo catalog', () => {
+ test('lists products and maps one-time and recurring prices', async () => {
+ const provider = dodo({
+ apiKey: 'dodo_test_123',
+ baseUrl: 'https://test.dodopayments.com',
+ fetch: (async (input) => {
+ expect(String(input)).toBe(
+ 'https://test.dodopayments.com/products?page_size=100',
+ );
+
+ return Response.json({
+ items: [
+ {
+ product_id: 'prod_one',
+ name: 'One Time',
+ description: 'One-time product',
+ is_recurring: false,
+ currency: 'USD',
+ price: 4900,
+ price_detail: {
+ type: 'one_time_price',
+ currency: 'USD',
+ price: 4900,
+ },
+ metadata: {
+ version: 'v1',
+ },
+ },
+ {
+ product_id: 'prod_recurring',
+ name: 'Recurring',
+ description: 'Recurring product',
+ is_recurring: true,
+ currency: 'EUR',
+ price: 2900,
+ price_detail: {
+ type: 'recurring_price',
+ currency: 'EUR',
+ price: 2900,
+ payment_frequency_count: 1,
+ payment_frequency_interval: 'Month',
+ subscription_period_count: 1,
+ subscription_period_interval: 'Month',
+ },
+ metadata: {},
+ },
+ ],
+ });
+ }) as typeof fetch,
+ });
+
+ const catalog = await provider.catalog?.list();
+
+ expect(catalog?.products).toHaveLength(2);
+ expect(catalog?.products[0]).toMatchObject({
+ id: 'prod_one',
+ sandbox: true,
+ name: 'One Time',
+ description: 'One-time product',
+ active: true,
+ version: 'v1',
+ });
+ expect(catalog?.prices).toHaveLength(2);
+ expect(catalog?.prices[0]).toMatchObject({
+ id: 'prod_one_price',
+ type: 'one_time',
+ currency: 'usd',
+ amount: 4900,
+ });
+ expect(catalog?.prices[1]).toMatchObject({
+ id: 'prod_recurring_price',
+ type: 'recurring',
+ currency: 'eur',
+ amount: 2900,
+ interval: 'month',
+ intervalCount: 1,
+ });
+ });
+});
diff --git a/packages/dodo/test/customers.test.ts b/packages/dodo/test/customers.test.ts
new file mode 100644
index 0000000..c783432
--- /dev/null
+++ b/packages/dodo/test/customers.test.ts
@@ -0,0 +1,179 @@
+import { describe, expect, test } from 'bun:test';
+import { createClient } from 'paymesh';
+import { dodo } from '../src';
+
+function expectType(_value: T) {}
+
+describe('dodo customers', () => {
+ test('manages Dodo customers through the client', async () => {
+ const requests: Array<{
+ input: string;
+ method?: string;
+ body?: Record;
+ }> = [];
+ const provider = dodo({
+ apiKey: 'dodo_test_123',
+ });
+ const client = createClient({
+ provider,
+ baseUrl: 'https://dodo.customers.test',
+ timeout: 1234,
+ fetch: (async (input, init) => {
+ requests.push({
+ input: String(input),
+ method: init?.method,
+ body: init?.body ? JSON.parse(String(init.body)) : undefined,
+ });
+
+ if (String(input).endsWith('/customers') && init?.method === 'POST') {
+ return Response.json({
+ customer_id: 'cus_create',
+ email: 'ana@example.com',
+ name: 'Ana',
+ phone_number: '+5511999999999',
+ metadata: {
+ externalId: 'user_ext_123',
+ plan: 'pro',
+ },
+ });
+ }
+
+ if (String(input).endsWith('/customers/cus_create')) {
+ if (init?.method === 'PATCH') {
+ return Response.json({
+ customer_id: 'cus_create',
+ email: 'ana@example.com',
+ name: 'Ana Silva',
+ phone_number: '+5511999999999',
+ metadata: {
+ externalId: 'user_ext_123',
+ plan: 'business',
+ },
+ });
+ }
+
+ return Response.json({
+ customer_id: 'cus_create',
+ email: 'ana@example.com',
+ name: 'Ana',
+ phone_number: '+5511999999999',
+ metadata: {
+ externalId: 'user_ext_123',
+ plan: 'pro',
+ },
+ });
+ }
+
+ return new Response('not found', { status: 404 });
+ }) as typeof fetch,
+ });
+
+ const created = await client.customers.upsert({
+ name: 'Ana',
+ email: 'ana@example.com',
+ phone: '+5511999999999',
+ externalId: 'user_ext_123',
+ metadata: {
+ plan: 'pro',
+ },
+ });
+ const found = await client.customers.get('cus_create');
+ const updated = await client.customers.upsert({
+ id: 'cus_create',
+ name: 'Ana Silva',
+ metadata: {
+ plan: 'business',
+ },
+ });
+
+ expect(requests[0]).toMatchObject({
+ input: 'https://dodo.customers.test/customers',
+ method: 'POST',
+ });
+ expect(requests[0]?.body).toEqual({
+ email: 'ana@example.com',
+ name: 'Ana',
+ phone_number: '+5511999999999',
+ metadata: {
+ plan: 'pro',
+ externalId: 'user_ext_123',
+ },
+ });
+ expect(requests[1]).toMatchObject({
+ input: 'https://dodo.customers.test/customers/cus_create',
+ method: undefined,
+ });
+ expect(requests[2]).toMatchObject({
+ input: 'https://dodo.customers.test/customers/cus_create',
+ method: 'PATCH',
+ });
+ expect(requests[2]?.body).toEqual({
+ email: undefined,
+ name: 'Ana Silva',
+ phone_number: undefined,
+ metadata: {
+ plan: 'business',
+ },
+ });
+ expect(created).toMatchObject({
+ id: 'cus_create',
+ provider: 'dodo',
+ externalId: 'user_ext_123',
+ name: 'Ana',
+ email: 'ana@example.com',
+ phone: '+5511999999999',
+ metadata: {
+ externalId: 'user_ext_123',
+ plan: 'pro',
+ },
+ });
+ expect(found.id).toBe('cus_create');
+ expect(updated.name).toBe('Ana Silva');
+ });
+
+ test('supports raw customer payloads and explicit delete failures', async () => {
+ const provider = dodo({
+ apiKey: 'dodo_test_123',
+ baseUrl: 'https://dodo.customers.test',
+ fetch: (async () =>
+ Response.json({
+ customer_id: 'cus_raw',
+ email: 'ana@example.com',
+ name: 'Ana',
+ metadata: {
+ externalId: 'user_ext_123',
+ },
+ })) as unknown as typeof fetch,
+ });
+ const rawClient = createClient({ provider, includeRaw: true });
+
+ const customer = await rawClient.customers.get('cus_raw');
+
+ expectType(customer.raw);
+ expect(customer.raw).toMatchObject({
+ customer_id: 'cus_raw',
+ });
+ expect(customer.externalId).toBe('user_ext_123');
+
+ await expect(rawClient.customers.delete('cus_raw')).rejects.toMatchObject({
+ code: 'unsupported_capability',
+ message: 'Provider "dodo" does not support deleting customers.',
+ provider: 'dodo',
+ });
+ });
+
+ test('requires email when creating customers', async () => {
+ const provider = dodo({
+ apiKey: 'dodo_test_123',
+ });
+
+ await expect(
+ provider.customers.upsert({
+ name: 'Ana',
+ }),
+ ).rejects.toMatchObject({
+ code: 'invalid_request',
+ message: 'Provider "dodo" requires "email" when creating customers',
+ });
+ });
+});
diff --git a/packages/dodo/test/dashboard.test.ts b/packages/dodo/test/dashboard.test.ts
new file mode 100644
index 0000000..7a0aca5
--- /dev/null
+++ b/packages/dodo/test/dashboard.test.ts
@@ -0,0 +1,226 @@
+import { describe, expect, test } from 'bun:test';
+import {
+ type CompiledQuery,
+ type PaymeshDatabaseDriver,
+ resolveDatabaseSchema,
+} from 'paymesh';
+import { dodo } from '../src';
+
+describe('dodo dashboard adapter', () => {
+ test('syncs customer, payment, pix-shaped payment, and subscriptions', async () => {
+ let syncedCustomerId: string | undefined;
+ let syncedInvoiceId: string | undefined;
+ let syncedPixId: string | undefined;
+ let syncedSubscriptionId: string | undefined;
+ const provider = dodo({
+ apiKey: 'dodo_test_123',
+ baseUrl: 'https://test.dodopayments.com',
+ fetch: (async (input) => {
+ if (String(input).endsWith('/customers/cus_123')) {
+ return Response.json({
+ customer_id: 'cus_123',
+ email: 'ana@example.com',
+ name: 'Ana',
+ metadata: {
+ externalId: 'user_123',
+ },
+ });
+ }
+
+ if (String(input).endsWith('/payments/pay_123')) {
+ return Response.json({
+ payment_id: 'pay_123',
+ total_amount: 4200,
+ currency: 'USD',
+ customer: {
+ customer_id: 'cus_123',
+ email: 'ana@example.com',
+ name: 'Ana',
+ },
+ metadata: {},
+ payment_link: 'https://pay.dodo.test/pay_123',
+ status: 'succeeded',
+ });
+ }
+
+ if (String(input).endsWith('/payments/pay_pix_123')) {
+ return Response.json({
+ payment_id: 'pay_pix_123',
+ total_amount: 3100,
+ currency: 'BRL',
+ customer: {
+ customer_id: 'cus_123',
+ email: 'ana@example.com',
+ name: 'Ana',
+ },
+ metadata: {},
+ payment_link: 'https://pay.dodo.test/pay_pix_123',
+ payment_method_type: 'pix',
+ status: 'processing',
+ });
+ }
+
+ if (String(input).endsWith('/subscriptions/sub_123')) {
+ return Response.json({
+ subscription_id: 'sub_123',
+ product_id: 'prod_123',
+ recurring_pre_tax_amount: 3900,
+ currency: 'USD',
+ status: 'active',
+ cancel_at_next_billing_date: false,
+ customer: {
+ customer_id: 'cus_123',
+ email: 'ana@example.com',
+ name: 'Ana',
+ },
+ metadata: {},
+ });
+ }
+
+ return new Response('not found', { status: 404 });
+ }) as typeof fetch,
+ });
+ const database = createDatabaseStub({
+ onCustomerUpsert(id) {
+ syncedCustomerId = id;
+ },
+ onInvoiceUpsert(id) {
+ syncedInvoiceId = id;
+ },
+ onPixUpsert(id) {
+ syncedPixId = id;
+ },
+ onSubscriptionUpsert(id) {
+ syncedSubscriptionId = id;
+ },
+ });
+
+ const customer = await provider.dashboard?.syncCustomer?.({
+ database,
+ id: 'cus_123',
+ schema: createSchema(),
+ });
+ const payment = await provider.dashboard?.syncPayment?.({
+ database,
+ id: 'pay_123',
+ schema: createSchema(),
+ });
+ const pix = await provider.dashboard?.syncPayment?.({
+ database,
+ id: 'pay_pix_123',
+ schema: createSchema(),
+ });
+ const subscription = await provider.dashboard?.syncSubscription?.({
+ database,
+ id: 'sub_123',
+ schema: createSchema(),
+ });
+
+ expect(
+ provider.dashboard?.getResourceUrl?.({ type: 'payment', id: 'pay_123' }),
+ ).toBeNull();
+ expect(customer?.id).toBe('cus_123');
+ expect(payment?.id).toBe('pay_123');
+ expect(pix).toMatchObject({
+ id: 'pay_pix_123',
+ method: 'pix',
+ status: 'processing',
+ });
+ expect(subscription?.id).toBe('sub_123');
+ expect(syncedCustomerId).toBe('cus_123');
+ expect(syncedInvoiceId).toBe('pay_pix_123');
+ expect(syncedPixId).toBe('pay_pix_123');
+ expect(syncedSubscriptionId).toBe('sub_123');
+ });
+});
+
+function createDatabaseStub(options: {
+ onCustomerUpsert(id: string): void;
+ onInvoiceUpsert(id: string): void;
+ onPixUpsert(id: string): void;
+ onSubscriptionUpsert(id: string): void;
+}) {
+ return {
+ id: 'stub-db',
+ type: 'database',
+ dialect: 'postgres',
+ persistRaw: true,
+ repositories: {
+ customers: {
+ async findByProviderId() {
+ return null;
+ },
+ async upsert(_schema: unknown, customer: { id: string }) {
+ options.onCustomerUpsert(customer.id);
+ },
+ async list() {
+ return { data: [], next: null, previous: null, total: 0 };
+ },
+ async markDeleted() {},
+ },
+ pix: {
+ async findByProviderId() {
+ return null;
+ },
+ async upsert(_schema: unknown, pix: { id: string }) {
+ options.onPixUpsert(pix.id);
+ },
+ },
+ checkouts: {
+ async findByProviderId() {
+ return null;
+ },
+ async upsert() {},
+ },
+ invoices: {
+ async findByProviderId() {
+ return null;
+ },
+ async upsert(_schema: unknown, payment: { id: string }) {
+ options.onInvoiceUpsert(payment.id);
+ },
+ },
+ subscriptions: {
+ async findByProviderId() {
+ return null;
+ },
+ async upsert(_schema: unknown, event: { data?: { id?: string } }) {
+ options.onSubscriptionUpsert(event.data?.id ?? 'missing');
+ },
+ },
+ webhookEvents: {
+ async acquire() {
+ return { duplicate: false };
+ },
+ async markProcessed() {},
+ async markFailed() {},
+ },
+ products: {
+ async upsertMany() {},
+ },
+ prices: {
+ async upsertMany() {},
+ },
+ migrations: {
+ async ensureTable() {},
+ async listApplied() {
+ return [];
+ },
+ async recordApplied() {},
+ },
+ },
+ async query(_query: CompiledQuery) {
+ return [] as Row[];
+ },
+ async execute(_query: CompiledQuery) {},
+ async transaction(
+ callback: (database: PaymeshDatabaseDriver) => Promise,
+ ) {
+ return callback(this as unknown as PaymeshDatabaseDriver);
+ },
+ } as unknown as PaymeshDatabaseDriver;
+}
+
+function createSchema() {
+ return resolveDatabaseSchema();
+}
diff --git a/packages/dodo/test/dodo.test.ts b/packages/dodo/test/dodo.test.ts
new file mode 100644
index 0000000..cdd221d
--- /dev/null
+++ b/packages/dodo/test/dodo.test.ts
@@ -0,0 +1,250 @@
+import { describe, expect, test } from 'bun:test';
+import { createClient } from 'paymesh';
+import { dodo } from '../src';
+
+function expectType(_value: T) {}
+
+describe('dodo provider', () => {
+ test('creates a hosted payment link using Dodo product carts', async () => {
+ const provider = dodo({
+ apiKey: 'dodo_test_123',
+ baseUrl: 'https://test.dodopayments.com',
+ fetch: (async (input, init) => {
+ expect(String(input)).toBe('https://test.dodopayments.com/payments');
+ expect(init?.method).toBe('POST');
+ expect(init?.headers).toEqual({
+ authorization: 'Bearer dodo_test_123',
+ 'content-type': 'application/json',
+ });
+
+ const body = JSON.parse(String(init?.body));
+ expect(body).toEqual({
+ billing: {
+ country: 'BR',
+ },
+ customer: {
+ email: 'ana@example.com',
+ name: 'Ana',
+ phone_number: '+5511999999999',
+ },
+ product_cart: [{ product_id: 'prod_123', quantity: 1, amount: 4900 }],
+ allowed_payment_method_types: ['pix', 'credit', 'debit'],
+ billing_currency: 'BRL',
+ metadata: {
+ externalId: 'user_123',
+ orderId: 'order_123',
+ },
+ payment_link: true,
+ return_url: 'https://app.test/success',
+ show_saved_payment_methods: false,
+ });
+
+ return Response.json({
+ payment_id: 'pay_123',
+ total_amount: 4900,
+ currency: 'BRL',
+ payment_link: 'https://pay.dodo.test/pay_123',
+ metadata: {
+ externalId: 'user_123',
+ orderId: 'order_123',
+ },
+ customer: {
+ customer_id: 'cus_123',
+ email: 'ana@example.com',
+ name: 'Ana',
+ phone_number: '+5511999999999',
+ metadata: {
+ externalId: 'user_123',
+ },
+ },
+ status: 'processing',
+ });
+ }) as typeof fetch,
+ });
+
+ const payment = await provider.payments.create({
+ amount: 4900,
+ currency: 'BRL',
+ productIds: ['prod_123'],
+ customer: {
+ email: 'ana@example.com',
+ name: 'Ana',
+ phone: '+5511999999999',
+ externalId: 'user_123',
+ },
+ successUrl: 'https://app.test/success',
+ metadata: {
+ externalId: 'user_123',
+ orderId: 'order_123',
+ },
+ });
+
+ expect(payment).toMatchObject({
+ id: 'pay_123',
+ provider: 'dodo',
+ sandbox: true,
+ amount: 4900,
+ currency: 'brl',
+ status: 'processing',
+ checkoutUrl: 'https://pay.dodo.test/pay_123',
+ customer: {
+ id: 'cus_123',
+ email: 'ana@example.com',
+ name: 'Ana',
+ externalId: 'user_123',
+ },
+ metadata: {
+ externalId: 'user_123',
+ orderId: 'order_123',
+ },
+ });
+ expect(payment.raw).toBeNull();
+ });
+
+ test('uses client request options and supports raw payload toggles', async () => {
+ let attempts = 0;
+ let timeoutSignal: AbortSignal | undefined;
+ const provider = dodo({
+ apiKey: 'dodo_test_123',
+ });
+ const client = createClient({
+ provider,
+ baseUrl: 'https://dodo.client.test',
+ timeout: 1234,
+ retry: {
+ max: 1,
+ },
+ fetch: (async (input, init) => {
+ attempts += 1;
+ timeoutSignal = init?.signal as AbortSignal;
+
+ expect(String(input)).toBe('https://dodo.client.test/payments');
+
+ if (attempts === 1) {
+ return new Response('busy', { status: 500 });
+ }
+
+ return Response.json({
+ payment_id: 'pay_retry',
+ total_amount: 1200,
+ currency: 'USD',
+ customer: {
+ customer_id: 'cus_retry',
+ email: 'retry@example.com',
+ name: 'Retry',
+ },
+ metadata: {},
+ payment_link: 'https://pay.dodo.test/retry',
+ status: 'succeeded',
+ });
+ }) as typeof fetch,
+ });
+ const rawClient = createClient({ provider, includeRaw: true });
+
+ const payment = await client.payments.create({
+ productIds: ['prod_123'],
+ customer: {
+ email: 'retry@example.com',
+ },
+ });
+ const rawPayment = await rawClient.payments.create(
+ {
+ productIds: ['prod_123'],
+ customer: {
+ email: 'raw@example.com',
+ },
+ },
+ {
+ includeRaw: true,
+ baseUrl: 'https://dodo.raw.test',
+ fetch: (async () =>
+ Response.json({
+ payment_id: 'pay_raw',
+ total_amount: 900,
+ currency: 'USD',
+ customer: {
+ customer_id: 'cus_raw',
+ email: 'raw@example.com',
+ name: 'Raw',
+ },
+ metadata: {},
+ payment_link: 'https://pay.dodo.test/raw',
+ status: 'processing',
+ })) as unknown as typeof fetch,
+ },
+ );
+
+ expect(attempts).toBe(2);
+ expect(timeoutSignal).toBeInstanceOf(AbortSignal);
+ expect(payment.status).toBe('paid');
+ expect(payment.raw).toBeNull();
+ expectType(rawPayment.raw);
+ expect(rawPayment.raw).toMatchObject({
+ payment_id: 'pay_raw',
+ });
+ });
+
+ test('requires productIds and customer identity', async () => {
+ const provider = dodo({
+ apiKey: 'dodo_test_123',
+ });
+
+ expect(
+ provider.payments.create({
+ customer: {
+ email: 'ana@example.com',
+ },
+ }),
+ ).rejects.toMatchObject({
+ code: 'invalid_request',
+ message:
+ 'Provider "dodo" requires at least one product id in "productIds"',
+ });
+
+ expect(
+ provider.payments.create({
+ productIds: ['prod_1', 'prod_2'],
+ amount: 1000,
+ customer: {
+ email: 'ana@example.com',
+ },
+ }),
+ ).rejects.toMatchObject({
+ code: 'invalid_request',
+ message:
+ 'Provider "dodo" only accepts "amount" when exactly one product id is provided.',
+ });
+
+ expect(
+ provider.payments.create({
+ productIds: ['prod_123'],
+ }),
+ ).rejects.toMatchObject({
+ code: 'invalid_request',
+ message:
+ 'Provider "dodo" requires either "customer.id" or "customer.email" when creating payments.',
+ });
+ });
+
+ test('infers sandbox from the configured base URL', () => {
+ expect(dodo({ baseUrl: 'https://test.dodopayments.com' }).isSandbox()).toBe(
+ true,
+ );
+ expect(dodo({ baseUrl: 'https://live.dodopayments.com' }).isSandbox()).toBe(
+ false,
+ );
+ });
+
+ test('advertises the supported Dodo capability surface', () => {
+ expect(dodo().capabilities).toEqual({
+ checkout: true,
+ pix: false,
+ coupons: false,
+ refunds: false,
+ subscriptions: true,
+ webhooks: true,
+ customerPortal: false,
+ customers: true,
+ });
+ });
+});
diff --git a/packages/dodo/test/webhooks.test.ts b/packages/dodo/test/webhooks.test.ts
new file mode 100644
index 0000000..d9d7211
--- /dev/null
+++ b/packages/dodo/test/webhooks.test.ts
@@ -0,0 +1,203 @@
+import { describe, expect, test } from 'bun:test';
+import { dodo } from '../src';
+import { signDodoWebhook } from '../src/shared/utils';
+
+describe('dodo webhooks', () => {
+ test('verifies valid webhook signatures and handles payment events', async () => {
+ const provider = dodo({
+ webhookSecret: 'whsec_c2VjcmV0X2Rlc2lnbg==',
+ baseUrl: 'https://test.dodopayments.com',
+ });
+ const request = dodoWebhookRequest(
+ {
+ business_id: 'biz_123',
+ type: 'payment.succeeded',
+ timestamp: new Date().toISOString(),
+ data: {
+ payment_id: 'pay_123',
+ total_amount: 1200,
+ currency: 'USD',
+ customer: {
+ customer_id: 'cus_123',
+ email: 'ana@example.com',
+ name: 'Ana',
+ metadata: {
+ externalId: 'user_123',
+ },
+ },
+ metadata: {},
+ payment_link: 'https://pay.dodo.test/pay_123',
+ status: 'succeeded',
+ },
+ },
+ 'whsec_c2VjcmV0X2Rlc2lnbg==',
+ );
+
+ const valid = await provider.webhooks?.verify({
+ request: request.clone(),
+ });
+ const handled = await provider.webhooks?.handle({
+ request,
+ includeRaw: true,
+ });
+
+ expect(valid).toBe(true);
+ expect(handled?.deliveryId).toBe('msg_123');
+ expect(handled?.hook).toBe('onPaymentSucceeded');
+ expect(handled?.event).toMatchObject({
+ id: 'pay_123',
+ type: 'payment.succeeded',
+ provider: 'dodo',
+ sandbox: true,
+ data: {
+ id: 'pay_123',
+ status: 'paid',
+ customer: {
+ id: 'cus_123',
+ externalId: 'user_123',
+ },
+ raw: {
+ payment_id: 'pay_123',
+ },
+ },
+ raw: {
+ type: 'payment.succeeded',
+ },
+ });
+ });
+
+ test('maps Pix-shaped payment and subscription cancellation webhooks', async () => {
+ const provider = dodo({
+ webhookSecret: 'whsec_c2VjcmV0X2Rlc2lnbg==',
+ baseUrl: 'https://test.dodopayments.com',
+ });
+ const pixHandled = await provider.webhooks?.handle({
+ request: new Request('https://app.test/webhooks', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'webhook-id': 'msg_pix',
+ },
+ body: JSON.stringify({
+ business_id: 'biz_123',
+ type: 'payment.processing',
+ timestamp: new Date().toISOString(),
+ data: {
+ payment_id: 'pay_pix_123',
+ total_amount: 3100,
+ currency: 'BRL',
+ customer: {
+ customer_id: 'cus_123',
+ email: 'ana@example.com',
+ name: 'Ana',
+ },
+ metadata: {},
+ payment_link: 'https://pay.dodo.test/pay_pix_123',
+ payment_method_type: 'pix',
+ status: 'processing',
+ },
+ }),
+ }),
+ });
+ const subscriptionHandled = await provider.webhooks?.handle({
+ request: new Request('https://app.test/webhooks', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'webhook-id': 'msg_sub',
+ },
+ body: JSON.stringify({
+ business_id: 'biz_123',
+ type: 'subscription.cancelled',
+ timestamp: new Date().toISOString(),
+ data: {
+ subscription_id: 'sub_123',
+ product_id: 'prod_123',
+ recurring_pre_tax_amount: 3900,
+ currency: 'USD',
+ status: 'cancelled',
+ cancel_at_next_billing_date: true,
+ customer: {
+ customer_id: 'cus_123',
+ email: 'ana@example.com',
+ name: 'Ana',
+ },
+ metadata: {},
+ },
+ }),
+ }),
+ });
+
+ expect(pixHandled?.event).toMatchObject({
+ type: 'payment.created',
+ data: {
+ id: 'pay_pix_123',
+ method: 'pix',
+ status: 'processing',
+ },
+ });
+ expect(pixHandled?.hook).toBe('onPaymentCreated');
+ expect(subscriptionHandled?.event).toMatchObject({
+ id: 'sub_123',
+ type: 'subscription.canceled',
+ data: {
+ id: 'sub_123',
+ status: 'cancelled',
+ },
+ });
+ expect(subscriptionHandled?.hook).toBe('onSubscriptionCanceled');
+ });
+
+ test('rejects invalid webhook signatures', async () => {
+ const provider = dodo({
+ webhookSecret: 'whsec_c2VjcmV0X2Rlc2lnbg==',
+ });
+ const valid = await provider.webhooks?.verify({
+ request: dodoWebhookRequest(
+ {
+ business_id: 'biz_123',
+ type: 'payment.failed',
+ timestamp: new Date().toISOString(),
+ data: {
+ payment_id: 'pay_123',
+ total_amount: 1200,
+ currency: 'USD',
+ customer: {
+ customer_id: 'cus_123',
+ email: 'ana@example.com',
+ name: 'Ana',
+ },
+ metadata: {},
+ status: 'failed',
+ },
+ },
+ 'whsec_d3Jvbmdfa2V5',
+ ),
+ });
+
+ expect(valid).toBe(false);
+ });
+});
+
+function dodoWebhookRequest(payload: unknown, secret: string) {
+ const body = JSON.stringify(payload);
+ const webhookId = 'msg_123';
+ const timestamp = String(Math.floor(Date.now() / 1000));
+ const signature = signDodoWebhook({
+ payload: body,
+ secret,
+ timestamp,
+ webhookId,
+ });
+
+ return new Request('https://app.test/webhooks', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'webhook-id': webhookId,
+ 'webhook-signature': signature,
+ 'webhook-timestamp': timestamp,
+ },
+ body,
+ });
+}
diff --git a/packages/dodo/tsconfig.json b/packages/dodo/tsconfig.json
new file mode 100644
index 0000000..d6b0121
--- /dev/null
+++ b/packages/dodo/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "paymesh": ["../paymesh/src/index.ts"]
+ },
+ "types": ["bun"]
+ },
+ "include": ["src/**/*.ts", "test/**/*.ts"]
+}
diff --git a/packages/dodo/tsdown.config.mjs b/packages/dodo/tsdown.config.mjs
new file mode 100644
index 0000000..21f6bf4
--- /dev/null
+++ b/packages/dodo/tsdown.config.mjs
@@ -0,0 +1,12 @@
+import { defineConfig } from 'tsdown';
+
+export default defineConfig({
+ entry: ['./src/index.ts'],
+ format: ['esm', 'cjs'],
+ dts: true,
+ clean: true,
+ sourcemap: false,
+ minify: true,
+ target: 'node20',
+ outDir: 'dist',
+});
From 531cd6571683ae18bb235416a6e6bd0d8786afa8 Mon Sep 17 00:00:00 2001
From: almeida
Date: Sat, 13 Jun 2026 10:53:35 -0300
Subject: [PATCH 03/15] docs: document dodo provider
---
README.md | 6 +++++-
package.json | 2 +-
packages/cli/src/shared/providers.ts | 7 +++++++
packages/paymesh/README.md | 8 ++++++++
4 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index d240fca..4a70e42 100644
--- a/README.md
+++ b/README.md
@@ -82,7 +82,7 @@ npm install paymesh @paymesh/stripe @paymesh/prisma @prisma/client
```
- Available providers currently include @paymesh/stripe and @paymesh/polar.
+ Available providers currently include @paymesh/stripe, @paymesh/polar, @paymesh/abacatepay, and @paymesh/dodo.
@@ -99,6 +99,10 @@ npm install paymesh @paymesh/stripe @paymesh/prisma @prisma/client
Native PIX flows live under paymesh.pix. Use paymesh.payments for generic checkout sessions and paymesh.pix when you need QR code, copia-e-cola, and expiration data as first-class fields.
+
+ @paymesh/dodo supports hosted BRL checkout links that can present Pix inside the Dodo checkout page, but it intentionally does not expose paymesh.pix because Dodo does not currently document a native QR-code-first backend PIX flow.
+
+
Database packages: @paymesh/postgres, @paymesh/drizzle, and @paymesh/prisma. Operational tooling: @paymesh/cli.
diff --git a/package.json b/package.json
index 0d69c1a..c51e66d 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,7 @@
"check": "biome check .",
"check:write": "biome check --write .",
"typecheck": "bun run typecheck:packages",
- "typecheck:packages": "bun run --filter './packages/{paymesh,audit-logs,memory,postgres,drizzle,prisma,stripe,polar,abacatepay,express,fastify,hono,elysia,next,cli,mcp}' typecheck",
+ "typecheck:packages": "bun run --filter './packages/{paymesh,audit-logs,memory,postgres,drizzle,prisma,stripe,polar,abacatepay,dodo,express,fastify,hono,elysia,next,cli,mcp}' typecheck",
"typecheck:apps": "bun run --filter './apps/*' typecheck",
"types": "bun run typecheck",
"test": "bun test --pass-with-no-tests",
diff --git a/packages/cli/src/shared/providers.ts b/packages/cli/src/shared/providers.ts
index 33a05a0..d2f663a 100644
--- a/packages/cli/src/shared/providers.ts
+++ b/packages/cli/src/shared/providers.ts
@@ -13,6 +13,13 @@ export const PROVIDERS = {
webhookSecretEnv: 'POLAR_WEBHOOK_SECRET',
paramName: 'accessToken',
},
+ dodo: {
+ label: 'Dodo Payments',
+ package: '@paymesh/dodo',
+ apiKeyEnv: 'DODO_PAYMENTS_API_KEY',
+ webhookSecretEnv: 'DODO_PAYMENTS_WEBHOOK_KEY',
+ paramName: 'apiKey',
+ },
custom: {
label: 'Custom — I will configure later',
package: null,
diff --git a/packages/paymesh/README.md b/packages/paymesh/README.md
index 4371b6e..9ac456c 100644
--- a/packages/paymesh/README.md
+++ b/packages/paymesh/README.md
@@ -29,6 +29,10 @@ npm install paymesh @paymesh/stripe @paymesh/postgres
For tests, CI, and local examples, use @paymesh/memory instead of a production database adapter.
+
+ Official provider packages currently include @paymesh/stripe, @paymesh/polar, @paymesh/abacatepay, and @paymesh/dodo.
+
+
Quickstart
@@ -77,6 +81,10 @@ console.log(payment.checkoutUrl, customer.email);
For native PIX flows, use paymesh.pix. This is separate from paymesh.payments so providers can expose QR code, copia-e-cola, and expiration details directly.
+
+ @paymesh/dodo supports hosted BRL checkout links that can offer Pix inside the redirect flow, but it intentionally does not expose paymesh.pix because Dodo does not currently document a native QR-code-first backend PIX flow.
+
+
```ts
const pix = await paymesh.pix.create({
amount: 3500,
From 1ddb86f6d0288863b5105b81f9dad90e5060c12d Mon Sep 17 00:00:00 2001
From: almeida
Date: Sat, 13 Jun 2026 11:02:03 -0300
Subject: [PATCH 04/15] chore(dodo): add it in changeset
---
.changeset/cyan-poems-boil.md | 8 ++++++++
1 file changed, 8 insertions(+)
create mode 100644 .changeset/cyan-poems-boil.md
diff --git a/.changeset/cyan-poems-boil.md b/.changeset/cyan-poems-boil.md
new file mode 100644
index 0000000..ef19d7e
--- /dev/null
+++ b/.changeset/cyan-poems-boil.md
@@ -0,0 +1,8 @@
+---
+"@paymesh/dodo": minor
+"@paymesh/cli": patch
+---
+
+Ship the first-party `@paymesh/dodo` provider with catalog-driven hosted checkout, customer upsert/get, catalog sync, dashboard sync helpers, and verified webhook normalization for Dodo Payments.
+
+Update `@paymesh/cli` provider selection so new setups can choose Dodo directly.
From 71b2f67ba73fa82720dd37803e88ae92e200a2a1 Mon Sep 17 00:00:00 2001
From: almeida
Date: Sat, 13 Jun 2026 11:02:19 -0300
Subject: [PATCH 05/15] fix(dodo): remove the coming-soon tag
---
apps/web/src/lib/docs.tsx | 248 ++++++++++++++++++++++++++++++++++----
1 file changed, 224 insertions(+), 24 deletions(-)
diff --git a/apps/web/src/lib/docs.tsx b/apps/web/src/lib/docs.tsx
index 3021d09..3ecb46f 100644
--- a/apps/web/src/lib/docs.tsx
+++ b/apps/web/src/lib/docs.tsx
@@ -429,6 +429,47 @@ export const app = new Elysia().post(
}),
);`;
+const dodoSetupSnippet = `import { createClient } from "paymesh";
+import { dodo } from "@paymesh/dodo";
+
+export const client = createClient({
+ provider: dodo({
+ apiKey: process.env.DODO_PAYMENTS_API_KEY!,
+ webhookSecret: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
+ baseUrl: "https://test.dodopayments.com",
+ }),
+});`;
+
+const dodoPaymentsSnippet = `const payment = await client.payments.create({
+ amount: 4900,
+ currency: "BRL",
+ productIds: ["prod_abc123"],
+ customer: {
+ email: "billing@example.com",
+ externalId: "order_42",
+ name: "Billing Team",
+ },
+ successUrl: "https://example.com/success",
+ cancelUrl: "https://example.com/cancel",
+ metadata: {
+ orderId: "order_42",
+ },
+});`;
+
+const dodoWebhookSnippet = `import { Elysia } from "elysia";
+import { Webhooks } from "@paymesh/elysia";
+
+export const app = new Elysia().post(
+ "/webhooks/dodo",
+ Webhooks({
+ client,
+
+ async onPaymentSucceeded(event) {
+ console.log("payment.succeeded", event.id);
+ },
+ }),
+);`;
+
const postgresSnippet = `import { createClient } from "paymesh";
import { postgres } from "@paymesh/postgres";
import { stripe } from "@paymesh/stripe";
@@ -827,8 +868,8 @@ const guidePages: DocPage[] = [
],
[
'Dodo',
- 'Another billing route for teams that want optionality.',
- 'Roadmap-only in the current repo build.',
+ 'Catalog-driven hosted checkout with Dodo Payments.',
+ 'No native paymesh.pix flow; Pix only appears inside BRL hosted checkout.',
],
]}
/>
@@ -1904,7 +1945,7 @@ const docsNavigation: DocNavGroup[] = [
status: 'coming-soon',
},
{ label: 'PayPal', slug: ['providers', 'paypal'], status: 'coming-soon' },
- { label: 'Dodo', slug: ['providers', 'dodo'], status: 'coming-soon' },
+ { label: 'Dodo', slug: ['providers', 'dodo'] },
],
},
{
@@ -2126,6 +2167,65 @@ const polarCapabilityRows: ReactNode[][] = [
],
];
+const dodoCapabilityRows: ReactNode[][] = [
+ [
+ checkout,
+
+ Available
+ ,
+ 'Creates Dodo hosted payments through catalog-driven product carts.',
+ ],
+ [
+ customers,
+
+ Available
+ ,
+ 'Upserts and fetches Dodo customers.',
+ ],
+ [
+ webhooks,
+
+ Available
+ ,
+ 'Verifies webhook-id, webhook-timestamp, and webhook-signature headers before dispatching normalized hooks.',
+ ],
+ [
+ subscriptions,
+
+ Advertised
+ ,
+ 'Subscription webhook events and dashboard sync are implemented.',
+ ],
+ [
+ pix,
+
+ False
+ ,
+ 'Dodo can offer hosted Pix inside BRL checkout links, but it does not expose native paymesh.pix flows.',
+ ],
+ [
+ refunds,
+
+ False
+ ,
+ 'Refund webhooks are normalized, but the provider does not expose refund helpers as a public capability.',
+ ],
+ [
+ customerPortal,
+
+ False
+ ,
+ 'No dedicated customer portal helper is exposed by the package.',
+ ],
+ [
+ coupons,
+
+ False
+ ,
+ 'No coupon capability is exposed by the package.',
+ ],
+];
+
const polarEventRows: ReactNode[][] = [
['checkout.created', 'payment.created', 'onPaymentCreated'],
['checkout.updated', 'checkout.completed', 'onCheckoutCompleted'],
@@ -2275,6 +2375,10 @@ const docsPages: DocPage[] = [
@paymesh/polar,
'You are using Polar as the upstream merchant-of-record provider.',
],
+ [
+ @paymesh/dodo,
+ 'You are using Dodo Payments for catalog-driven hosted checkout flows.',
+ ],
[
@paymesh/next,
'Your webhook endpoint runs in a Next.js App Router route.',
@@ -3277,10 +3381,10 @@ rawPayment.raw; // provider payload`}
],
[
'Dodo',
-
- Coming Soon
+
+ Available
,
- 'Planned additional modern billing route.',
+ 'Shipped catalog-driven provider with hosted checkout, customers, webhooks, catalog sync, and subscription sync.',
],
]}
/>
@@ -3702,6 +3806,120 @@ rawPayment.raw; // provider payload`}
},
],
},
+ {
+ group: 'Providers',
+ slug: ['providers', 'dodo'],
+ title: 'Dodo',
+ description:
+ 'Dodo is a shipped Paymesh provider for catalog-driven hosted checkout, customer operations, catalog sync, and normalized webhook handling.',
+ sections: [
+ {
+ id: 'setup',
+ title: 'Setup',
+ content: (
+
+
+ The Dodo provider lives in @paymesh/dodo{' '}
+ and reads DODO_PAYMENTS_API_KEY and
+ optional DODO_PAYMENTS_WEBHOOK_KEY by
+ default.
+
+
+
+ ),
+ },
+ {
+ id: 'capabilities',
+ title: 'Capabilities',
+ content: (
+
+
+
+ ),
+ },
+ {
+ id: 'payments',
+ title: 'Hosted Checkout Flow',
+ content: (
+
+
+ Dodo payment creation is catalog-driven. You must provide at least
+ one productIds entry, and{' '}
+ amount is only accepted when exactly one
+ product id is present.
+
+
+
+ For BRL hosted checkout links, the provider enables Dodo payment
+ methods that can include Pix inside the hosted checkout page. This
+ does not expose the dedicated client.pix{' '}
+ surface.
+
+
+ ),
+ },
+ {
+ id: 'customers',
+ title: 'Customer Operations',
+ content: (
+
+
+ The Dodo provider supports normalized{' '}
+ upsert and get{' '}
+ for customers. Delete is intentionally unsupported because the
+ package does not expose a provider-side customer delete endpoint.
+
+
+
+ ),
+ },
+ {
+ id: 'webhooks',
+ title: 'Webhook Mapping',
+ content: (
+
+
+ Dodo webhook verification reconstructs the standard signed message
+ using webhook-id,{' '}
+ webhook-timestamp, and the raw request
+ body before comparing the expected HMAC-SHA256 signature with the
+ v1 signature.
+
+
+
+
+ ),
+ },
+ ],
+ },
...[
{
slug: ['providers', 'abacatepay'],
@@ -3739,24 +3957,6 @@ rawPayment.raw; // provider payload`}
['refunds', 'planned'],
] as const,
},
- {
- slug: ['providers', 'dodo'],
- title: 'Dodo',
- packageName: '@paymesh/dodo',
- notes:
- 'Planned additional modern billing route for teams that want optionality beyond the first provider.',
- targets: [
- 'Consistent payment and customer primitives through the same core client.',
- 'Webhook normalization into Paymesh event hooks.',
- 'Provider capability modeling that matches the rest of the ecosystem.',
- ],
- capabilities: [
- ['checkout', 'planned'],
- ['customers', 'planned'],
- ['webhooks', 'planned'],
- ['subscriptions', 'planned'],
- ] as const,
- },
].map((provider) => ({
group: 'Providers',
slug: provider.slug,
From f1f0749f4d7e9d8d68297e411be61cd447c1f993 Mon Sep 17 00:00:00 2001
From: almeida
Date: Sat, 13 Jun 2026 11:04:02 -0300
Subject: [PATCH 06/15] chore: update dependencies
---
bun.lock | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/bun.lock b/bun.lock
index 743b8d3..b791dc1 100644
--- a/bun.lock
+++ b/bun.lock
@@ -89,6 +89,16 @@
"paymesh": ">=0.0.0",
},
},
+ "packages/dodo": {
+ "name": "@paymesh/dodo",
+ "version": "0.0.0",
+ "devDependencies": {
+ "paymesh": "workspace:*",
+ },
+ "peerDependencies": {
+ "paymesh": ">=0.0.0",
+ },
+ },
"packages/drizzle": {
"name": "@paymesh/drizzle",
"version": "0.0.0",
@@ -514,6 +524,8 @@
"@paymesh/dash": ["@paymesh/dash@workspace:packages/dash"],
+ "@paymesh/dodo": ["@paymesh/dodo@workspace:packages/dodo"],
+
"@paymesh/drizzle": ["@paymesh/drizzle@workspace:packages/drizzle"],
"@paymesh/elysia": ["@paymesh/elysia@workspace:packages/elysia"],
From 0f5de20f7cd82aecf8f0cf7ca83b83625fe9c92e Mon Sep 17 00:00:00 2001
From: almeida
Date: Sat, 13 Jun 2026 12:09:10 -0300
Subject: [PATCH 07/15] feat(paymesh): extend types and database repositories
to support coupons
---
packages/paymesh/src/client/coupons.ts | 215 +++++++++++++++++++++
packages/paymesh/src/client/managers.ts | 9 +
packages/paymesh/src/database/schema.ts | 1 +
packages/paymesh/src/types/client.ts | 83 ++++++++
packages/paymesh/src/types/database.ts | 90 +++++++++
packages/paymesh/src/types/providers.ts | 247 ++++++++++++++++++++++++
packages/paymesh/test/client.test.ts | 153 +++++++++++++++
7 files changed, 798 insertions(+)
create mode 100644 packages/paymesh/src/client/coupons.ts
diff --git a/packages/paymesh/src/client/coupons.ts b/packages/paymesh/src/client/coupons.ts
new file mode 100644
index 0000000..84a6c9b
--- /dev/null
+++ b/packages/paymesh/src/client/coupons.ts
@@ -0,0 +1,215 @@
+import { splitExtraFields } from '../shared/database/fields';
+import type {
+ PaymeshCoupon,
+ PaymeshCouponCheckResult,
+ PaymeshCouponCreateData,
+ PaymeshCouponList,
+ PaymeshCouponUpdateData,
+} from '../types/client';
+import type {
+ DatabaseSchemaOptions,
+ PaymeshCouponListOptions,
+ PaymeshDatabaseDriver,
+ ResolvedDatabaseSchema,
+} from '../types/database';
+import type {
+ CouponCheckData,
+ Provider,
+ ProviderRequestOptions,
+} from '../types/providers';
+import { getRequiredProviderFeature, resolveIncludeRaw } from './helpers';
+
+export function createCouponsClient<
+ Schema extends DatabaseSchemaOptions,
+ P extends Provider,
+ IncludeRaw extends boolean,
+>({
+ assertCapability,
+ baseIncludeRaw,
+ database,
+ mergeOptions,
+ provider,
+ schema,
+}: {
+ assertCapability: (capability: 'coupons') => void;
+ baseIncludeRaw: boolean;
+ database?: PaymeshDatabaseDriver;
+ mergeOptions: (
+ requestOptions?: ProviderRequestOptions,
+ ) => ProviderRequestOptions;
+ provider: P;
+ schema: ResolvedDatabaseSchema;
+}) {
+ const couponsRepository = database?.repositories.coupons;
+ const getProviderCoupons = () =>
+ getRequiredProviderFeature(provider.coupons, provider.id, 'coupons');
+
+ return {
+ create: async (
+ data: PaymeshCouponCreateData,
+ requestOptions?: ProviderRequestOptions,
+ ) => {
+ assertCapability('coupons');
+ const providerCoupons = getProviderCoupons();
+
+ const { input, extra } = splitExtraFields(
+ data,
+ schema.tables.coupons.fields,
+ );
+ const coupon = await providerCoupons.create(
+ input as Parameters[0],
+ mergeOptions(requestOptions),
+ );
+ const resolvedCoupon = Object.assign(coupon, extra) as PaymeshCoupon<
+ CallIncludeRaw,
+ Schema
+ >;
+
+ if (couponsRepository) {
+ await couponsRepository.upsert(schema, resolvedCoupon);
+ }
+
+ return resolvedCoupon;
+ },
+ get: async (
+ id: string,
+ requestOptions?: ProviderRequestOptions,
+ ) => {
+ assertCapability('coupons');
+ const providerCoupons = getProviderCoupons();
+ const mergedOptions = mergeOptions(requestOptions);
+
+ if (couponsRepository) {
+ const coupon = await couponsRepository.findByProviderId(
+ schema,
+ provider.id,
+ mergedOptions.sandbox ?? provider.isSandbox(),
+ id,
+ {
+ includeRaw: mergedOptions.includeRaw,
+ },
+ );
+
+ if (coupon) {
+ return coupon as PaymeshCoupon;
+ }
+ }
+
+ return providerCoupons.get(id, mergedOptions) as Promise<
+ PaymeshCoupon
+ >;
+ },
+ list: async (
+ options?: PaymeshCouponListOptions,
+ ) => {
+ assertCapability('coupons');
+ const providerCoupons = getProviderCoupons();
+
+ if (couponsRepository) {
+ const result = await couponsRepository.list(
+ schema,
+ provider.id,
+ options?.sandbox ?? provider.isSandbox(),
+ {
+ includeRaw: resolveIncludeRaw(
+ options?.includeRaw,
+ baseIncludeRaw,
+ ) as CallIncludeRaw,
+ limit: options?.limit,
+ after: options?.after,
+ before: options?.before,
+ code: options?.code,
+ active: options?.active,
+ },
+ );
+
+ return {
+ ...result,
+ data: result.data as Array>,
+ } as PaymeshCouponList;
+ }
+
+ return providerCoupons.list(
+ {
+ limit: options?.limit,
+ after: options?.after,
+ before: options?.before,
+ code: options?.code,
+ active: options?.active,
+ },
+ mergeOptions({
+ includeRaw: resolveIncludeRaw(
+ options?.includeRaw,
+ baseIncludeRaw,
+ ) as CallIncludeRaw,
+ sandbox: options?.sandbox,
+ }),
+ ) as Promise>;
+ },
+ update: async (
+ id: string,
+ data: PaymeshCouponUpdateData,
+ requestOptions?: ProviderRequestOptions,
+ ) => {
+ assertCapability('coupons');
+ const providerCoupons = getProviderCoupons();
+
+ const { input, extra } = splitExtraFields(
+ data,
+ schema.tables.coupons.fields,
+ );
+ const coupon = await providerCoupons.update(
+ id,
+ input as Parameters[1],
+ mergeOptions(requestOptions),
+ );
+ const resolvedCoupon = Object.assign(coupon, extra) as PaymeshCoupon<
+ CallIncludeRaw,
+ Schema
+ >;
+
+ if (couponsRepository) {
+ await couponsRepository.upsert(schema, resolvedCoupon);
+ }
+
+ return resolvedCoupon;
+ },
+ delete: async (
+ id: string,
+ requestOptions?: ProviderRequestOptions,
+ ) => {
+ assertCapability('coupons');
+ const providerCoupons = getProviderCoupons();
+ const result = await providerCoupons.delete(
+ id,
+ mergeOptions(requestOptions),
+ );
+
+ if (couponsRepository) {
+ await couponsRepository.markDeleted(schema, result);
+ }
+
+ return result;
+ },
+ check: async (
+ data: CouponCheckData,
+ requestOptions?: ProviderRequestOptions,
+ ) => {
+ assertCapability('coupons');
+ const providerCoupons = getProviderCoupons();
+ const result = await providerCoupons.check(
+ data,
+ mergeOptions(requestOptions),
+ );
+
+ if (couponsRepository && result.coupon) {
+ await couponsRepository.upsert(
+ schema,
+ result.coupon as PaymeshCoupon,
+ );
+ }
+
+ return result as PaymeshCouponCheckResult;
+ },
+ };
+}
diff --git a/packages/paymesh/src/client/managers.ts b/packages/paymesh/src/client/managers.ts
index 285a3fe..2807180 100644
--- a/packages/paymesh/src/client/managers.ts
+++ b/packages/paymesh/src/client/managers.ts
@@ -13,6 +13,7 @@ import type {
} from '../types/database';
import type { AnyPaymeshPlugin } from '../types/plugins';
import type { Provider, ProviderCapability } from '../types/providers';
+import { createCouponsClient } from './coupons';
import { createCustomersClient } from './customers';
import type { RuntimeHookDispatcher } from './helpers';
import { createPaymentsClient } from './payments';
@@ -104,6 +105,14 @@ export function createClientManagers<
provider,
schema,
}),
+ coupons: createCouponsClient({
+ assertCapability,
+ baseIncludeRaw,
+ database,
+ mergeOptions,
+ provider,
+ schema,
+ }),
webhooks: createWebhookClient({
baseIncludeRaw,
database,
diff --git a/packages/paymesh/src/database/schema.ts b/packages/paymesh/src/database/schema.ts
index 4afe2c7..0ed686e 100644
--- a/packages/paymesh/src/database/schema.ts
+++ b/packages/paymesh/src/database/schema.ts
@@ -22,6 +22,7 @@ export const PAYMESH_DEFAULT_SCHEMA_PREFIX = 'paymesh_';
*/
export const PAYMESH_DATABASE_TABLE_NAMES = {
customers: 'customers',
+ coupons: 'coupons',
pix: 'pix',
checkouts: 'checkouts',
invoices: 'invoices',
diff --git a/packages/paymesh/src/types/client.ts b/packages/paymesh/src/types/client.ts
index 2092ada..53e9398 100644
--- a/packages/paymesh/src/types/client.ts
+++ b/packages/paymesh/src/types/client.ts
@@ -3,6 +3,7 @@ import type {
DatabaseSchemaOptions,
DatabaseTableInputExtraFields,
DatabaseTableOutputExtraFields,
+ PaymeshCouponListOptions,
PaymeshCustomerListOptions,
PaymeshCustomerListResult,
PaymeshDatabaseDriver,
@@ -19,6 +20,13 @@ import type {
} from './plugins';
import type {
AnyPayment,
+ Coupon,
+ CouponCheckData,
+ CouponCheckResult,
+ CouponCreateData,
+ CouponDeleteResult,
+ CouponListResult,
+ CouponUpdateData,
Customer,
CustomerDeleteResult,
CustomerUpsertData,
@@ -283,6 +291,40 @@ export type PaymeshCustomerList<
PaymeshCustomerListResult>
>;
+/** Paymesh coupon input with schema-derived extra fields. */
+export type PaymeshCouponCreateData<
+ Schema extends DatabaseSchemaOptions = DatabaseSchemaOptions,
+> = Simplify<
+ CouponCreateData & DatabaseTableInputExtraFields
+>;
+
+/** Paymesh coupon update input with schema-derived extra fields. */
+export type PaymeshCouponUpdateData<
+ Schema extends DatabaseSchemaOptions = DatabaseSchemaOptions,
+> = Simplify<
+ CouponUpdateData & DatabaseTableInputExtraFields
+>;
+
+/** Paymesh coupon output with schema-derived extra fields. */
+export type PaymeshCoupon<
+ IncludeRaw extends boolean = false,
+ Schema extends DatabaseSchemaOptions = DatabaseSchemaOptions,
+> = Simplify<
+ Coupon & DatabaseTableOutputExtraFields
+>;
+
+/** Paginated coupon list using the Paymesh coupon shape. */
+export type PaymeshCouponList<
+ IncludeRaw extends boolean = false,
+ Schema extends DatabaseSchemaOptions = DatabaseSchemaOptions,
+> = Simplify>>;
+
+/** Coupon validation result using the Paymesh coupon shape. */
+export type PaymeshCouponCheckResult<
+ IncludeRaw extends boolean = false,
+ Schema extends DatabaseSchemaOptions = DatabaseSchemaOptions,
+> = CouponCheckResult>;
+
/** Payments sub-client exposed by `createClient`. */
export interface PaymeshPaymentsClient<
IncludeRaw extends boolean = false,
@@ -338,6 +380,45 @@ export interface PaymeshCustomersClient<
): Promise>;
}
+/** Coupons sub-client exposed by `createClient`. */
+export interface PaymeshCouponsClient<
+ IncludeRaw extends boolean = false,
+ Schema extends DatabaseSchemaOptions = DatabaseSchemaOptions,
+> {
+ /** Creates a coupon through the configured provider. */
+ create(
+ data: PaymeshCouponCreateData,
+ options?: ProviderRequestOptions,
+ ): Promise>;
+ /** Loads a coupon from the database or provider. */
+ get(
+ id: string,
+ options?: ProviderRequestOptions,
+ ): Promise>;
+ /** Lists coupons for the configured provider. */
+ list(
+ options?: PaymeshCouponListOptions,
+ ): Promise>;
+ /** Updates a coupon through the configured provider. */
+ update(
+ id: string,
+ data: PaymeshCouponUpdateData,
+ options?: ProviderRequestOptions,
+ ): Promise>;
+ /** Deletes a coupon through the configured provider. */
+ delete(
+ id: string,
+ options?: ProviderRequestOptions,
+ ): Promise>;
+ /** Validates a coupon code against the provided cart context. */
+ check(
+ data: CouponCheckData,
+ options?: ProviderRequestOptions,
+ ): Promise<
+ CouponCheckResult>
+ >;
+}
+
/** Full Paymesh client returned by `createClient`. */
export interface PaymeshClient<
IncludeRaw extends boolean = false,
@@ -362,6 +443,8 @@ export interface PaymeshClient<
pix: PaymeshPixClient;
/** Customers sub-client. */
customers: PaymeshCustomersClient;
+ /** Coupons sub-client. */
+ coupons: PaymeshCouponsClient;
/** Webhook handler contract. */
webhooks: PaymeshWebhookHandler;
/** Plugin route dispatcher. */
diff --git a/packages/paymesh/src/types/database.ts b/packages/paymesh/src/types/database.ts
index 576aebe..8672f62 100644
--- a/packages/paymesh/src/types/database.ts
+++ b/packages/paymesh/src/types/database.ts
@@ -1,11 +1,14 @@
import type {
AnyPayment,
BaseAnyPayment,
+ BaseCoupon,
+ BaseCouponDeleteResult,
BaseCustomer,
BaseCustomerDeleteResult,
BasePayment,
BasePaymeshEvent,
BasePix,
+ Coupon,
Customer,
Pix,
ProviderCatalogPrice,
@@ -17,6 +20,7 @@ import type {
*/
export type DatabaseTableKey =
| 'customers'
+ | 'coupons'
| 'pix'
| 'checkouts'
| 'invoices'
@@ -421,6 +425,43 @@ export interface PaymeshCustomerListResult<
next: string | null;
}
+/**
+ * Cursor and paging options for coupon lists.
+ */
+export interface PaymeshCouponListOptions {
+ /** Page size. */
+ limit?: number;
+ /** Cursor for results after the given page. */
+ after?: string;
+ /** Cursor for results before the given page. */
+ before?: string;
+ /** Filter by exact coupon code. */
+ code?: string;
+ /** Filter by active flag. */
+ active?: boolean;
+ /** Include raw payloads in returned values. Defaults to `false`. */
+ includeRaw?: IncludeRaw;
+ /** Overrides the sandbox mode for this query. Defaults to the provider sandbox mode. */
+ sandbox?: boolean;
+}
+
+/**
+ * Paginated coupon list response.
+ */
+export interface PaymeshCouponListResult<
+ IncludeRaw extends boolean = false,
+ TCoupon = Coupon,
+> {
+ /** Page items. */
+ data: TCoupon[];
+ /** Total matching count. */
+ total: number;
+ /** Cursor for the previous page. */
+ previous: string | null;
+ /** Cursor for the next page. */
+ next: string | null;
+}
+
/**
* Repository interface for customer persistence.
*/
@@ -480,6 +521,54 @@ export interface PaymeshCustomersRepository {
): Promise;
}
+/**
+ * Repository interface for coupon persistence.
+ */
+export interface PaymeshCouponsRepository {
+ /** Finds a coupon by provider id. */
+ findByProviderId<
+ IncludeRaw extends boolean = false,
+ TCoupon extends Coupon = Coupon,
+ >(
+ schema: ResolvedDatabaseSchema,
+ provider: string,
+ sandbox: boolean,
+ id: string,
+ options?: PaymeshRepositoryReadOptions,
+ ): Promise;
+ /** Finds a coupon by code. */
+ findByCode?<
+ IncludeRaw extends boolean = false,
+ TCoupon extends Coupon = Coupon,
+ >(
+ schema: ResolvedDatabaseSchema,
+ provider: string,
+ sandbox: boolean,
+ code: string,
+ options?: PaymeshRepositoryReadOptions,
+ ): Promise;
+ /** Inserts or updates a coupon row. */
+ upsert(
+ schema: ResolvedDatabaseSchema,
+ coupon: TCoupon,
+ ): Promise;
+ /** Lists coupons for a provider. */
+ list<
+ IncludeRaw extends boolean = false,
+ TCoupon extends Coupon = Coupon,
+ >(
+ schema: ResolvedDatabaseSchema,
+ provider: string,
+ sandbox: boolean,
+ options?: PaymeshCouponListOptions,
+ ): Promise>;
+ /** Marks a coupon as deleted. */
+ markDeleted(
+ schema: ResolvedDatabaseSchema,
+ coupon: BaseCouponDeleteResult,
+ ): Promise;
+}
+
/**
* Repository interface for checkout persistence.
*/
@@ -627,6 +716,7 @@ export interface PaymeshMigrationsRepository {
*/
export interface PaymeshDatabaseRepositories {
customers: PaymeshCustomersRepository;
+ coupons?: PaymeshCouponsRepository;
pix: PaymeshPixRepository;
checkouts: PaymeshCheckoutsRepository;
invoices: PaymeshInvoicesRepository;
diff --git a/packages/paymesh/src/types/providers.ts b/packages/paymesh/src/types/providers.ts
index 4c08798..0c16fea 100644
--- a/packages/paymesh/src/types/providers.ts
+++ b/packages/paymesh/src/types/providers.ts
@@ -62,6 +62,10 @@ export interface PaymentCreateData {
currency?: string;
/** Provider-specific product ids attached to the payment. Required by providers that price via catalog (e.g. AbacatePay). */
productIds?: string[];
+ /** Coupon or promotion code to pre-apply on the checkout. */
+ couponCode?: string;
+ /** Whether the hosted checkout should allow customers to enter coupon codes manually. */
+ allowCouponCodes?: boolean;
/** Optional customer context for the payment. */
customer?: PaymentCustomer;
@@ -205,6 +209,212 @@ export type CustomerDeleteResult = WithRaw<
IncludeRaw
>;
+/** Common coupon status values normalized across providers. */
+export type CouponStatus =
+ | 'active'
+ | 'inactive'
+ | 'scheduled'
+ | 'expired'
+ | 'deleted';
+
+/** Normalized coupon discount type. */
+export type CouponDiscountType = 'percentage' | 'fixed';
+
+/** Percentage-based discount definition. */
+export interface CouponPercentageDiscount {
+ type: 'percentage';
+ value: number;
+}
+
+/** Fixed-amount discount definition. */
+export interface CouponFixedDiscount {
+ type: 'fixed';
+ value: number;
+ currency?: string;
+ amounts?: Record;
+}
+
+/** Normalized discount definition accepted and returned by the coupons API. */
+export type CouponDiscount = CouponPercentageDiscount | CouponFixedDiscount;
+
+/** Duration modes supported by provider-side coupons. */
+export interface CouponDuration {
+ type: 'once' | 'forever' | 'repeating';
+ durationInMonths?: number;
+}
+
+/** Product or price restrictions applied to a coupon. */
+export interface CouponAppliesTo {
+ products?: string[];
+ prices?: string[];
+}
+
+/** Redemption counters exposed by providers. */
+export interface CouponRedemptions {
+ count: number;
+ max: number | null;
+}
+
+/** Normalized coupon payload returned by providers. */
+export interface BaseCoupon {
+ id: string;
+ provider: string;
+ sandbox: boolean;
+ code: string;
+ name?: string;
+ status: CouponStatus;
+ active?: boolean;
+ discount: CouponDiscount;
+ duration?: CouponDuration;
+ startsAt?: string;
+ expiresAt?: string;
+ customerId?: string;
+ firstTimeOnly?: boolean;
+ minimumAmount?: number;
+ minimumAmountCurrency?: string;
+ appliesTo?: CouponAppliesTo;
+ redemptions: CouponRedemptions;
+ metadata?: Record;
+}
+
+/** Coupon payload that optionally carries the raw provider response. */
+export type Coupon = WithRaw<
+ BaseCoupon,
+ IncludeRaw
+>;
+
+/** Input used to create a coupon or promotion code. */
+export interface CouponCreateData {
+ code: string;
+ name?: string;
+ discount: CouponDiscount;
+ duration?: CouponDuration;
+ startsAt?: Date | string;
+ expiresAt?: Date | string;
+ maxRedemptions?: number | null;
+ customerId?: string;
+ firstTimeOnly?: boolean;
+ minimumAmount?: number;
+ minimumAmountCurrency?: string;
+ appliesTo?: CouponAppliesTo;
+ metadata?: Record;
+}
+
+/** Input used to update a coupon or promotion code. */
+export interface CouponUpdateData {
+ code?: string;
+ name?: string;
+ active?: boolean;
+ discount?: CouponDiscount;
+ duration?: CouponDuration;
+ startsAt?: Date | string | null;
+ expiresAt?: Date | string | null;
+ maxRedemptions?: number | null;
+ customerId?: string | null;
+ firstTimeOnly?: boolean;
+ minimumAmount?: number | null;
+ minimumAmountCurrency?: string | null;
+ appliesTo?: CouponAppliesTo;
+ metadata?: Record;
+}
+
+/** Cursor-based filters supported when listing coupons. */
+export interface CouponListOptions {
+ limit?: number;
+ after?: string;
+ before?: string;
+ code?: string;
+ active?: boolean;
+ status?: CouponStatus;
+}
+
+/** Paginated coupon list response. */
+export interface CouponListResult<
+ IncludeRaw extends boolean = false,
+ TCoupon = Coupon,
+> {
+ data: TCoupon[];
+ total: number;
+ previous: string | null;
+ next: string | null;
+}
+
+/** Returned after deleting a coupon or promotion code. */
+export interface BaseCouponDeleteResult {
+ id: string;
+ provider: string;
+ sandbox: boolean;
+ deleted: boolean;
+ code?: string;
+}
+
+/** Coupon delete payload that optionally carries the raw provider response. */
+export type CouponDeleteResult = WithRaw<
+ BaseCouponDeleteResult,
+ IncludeRaw
+>;
+
+/** Reasons returned when coupon validation fails. */
+export type CouponInvalidReason =
+ | 'not_found'
+ | 'inactive'
+ | 'expired'
+ | 'not_started'
+ | 'max_redemptions_reached'
+ | 'customer_not_eligible'
+ | 'product_not_eligible'
+ | 'currency_not_supported'
+ | 'minimum_amount_not_reached'
+ | 'provider_error';
+
+/** Price preview returned when checking a coupon against a cart. */
+export interface CouponCheckPreview {
+ subtotal: number;
+ discountTotal: number;
+ total: number;
+ currency: string;
+}
+
+/** Input accepted by `coupons.check()`. */
+export interface CouponCheckData {
+ code: string;
+ customerId?: string;
+ productIds?: string[];
+ priceIds?: string[];
+ amount?: number;
+ currency?: string;
+}
+
+/** Successful coupon validation result. */
+export interface CouponCheckValidResult<
+ IncludeRaw extends boolean = false,
+ TCoupon = Coupon,
+> {
+ valid: true;
+ coupon: TCoupon;
+ preview?: CouponCheckPreview;
+}
+
+/** Failed coupon validation result. */
+export interface CouponCheckInvalidResult<
+ IncludeRaw extends boolean = false,
+ TCoupon = Coupon,
+> {
+ valid: false;
+ reason: CouponInvalidReason;
+ message?: string;
+ coupon?: TCoupon;
+ preview?: CouponCheckPreview;
+}
+
+/** Coupon validation result union. */
+export type CouponCheckResult<
+ IncludeRaw extends boolean = false,
+ TCoupon = Coupon,
+> =
+ | CouponCheckValidResult
+ | CouponCheckInvalidResult;
+
/** Request-level overrides passed to provider methods. */
export interface ProviderRequestOptions {
/** Overrides the provider base URL. */
@@ -360,6 +570,41 @@ export interface ProviderCustomers {
): Promise>;
}
+/** Coupon management contract. */
+export interface ProviderCoupons {
+ /** Loads a coupon by id. */
+ get(
+ id: string,
+ options?: ProviderRequestOptions,
+ ): Promise>;
+ /** Creates a coupon or promotion code. */
+ create(
+ data: CouponCreateData,
+ options?: ProviderRequestOptions,
+ ): Promise>;
+ /** Lists coupons exposed by the provider. */
+ list(
+ listOptions?: CouponListOptions,
+ options?: ProviderRequestOptions,
+ ): Promise>;
+ /** Updates a coupon or promotion code. */
+ update(
+ id: string,
+ data: CouponUpdateData,
+ options?: ProviderRequestOptions,
+ ): Promise>;
+ /** Deletes a coupon or promotion code. */
+ delete(
+ id: string,
+ options?: ProviderRequestOptions,
+ ): Promise>;
+ /** Validates a coupon against the current cart or amount context. */
+ check(
+ data: CouponCheckData,
+ options?: ProviderRequestOptions,
+ ): Promise>;
+}
+
/** Context passed to webhook verification. */
export interface ProviderVerifyWebhookContext {
request: Request;
@@ -456,6 +701,8 @@ export interface ProviderDefinition {
/** Customer management contract. */
customers: ProviderCustomers;
+ /** Optional coupon management contract. */
+ coupons?: ProviderCoupons;
/** Optional webhook contract. */
webhooks?: ProviderWebhooks;
diff --git a/packages/paymesh/test/client.test.ts b/packages/paymesh/test/client.test.ts
index 7a4d9bb..1e26276 100644
--- a/packages/paymesh/test/client.test.ts
+++ b/packages/paymesh/test/client.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, test } from 'bun:test';
import {
+ type Coupon,
+ type CouponCheckData,
+ type CouponCheckResult,
+ type CouponCreateData,
type Customer,
createClient,
defineDatabaseAdapter,
@@ -156,6 +160,35 @@ describe('client', () => {
expect(defaultPix.method).toBe('pix');
});
+ test('exposes coupons client methods and raw payload controls', async () => {
+ const provider = createStubProvider();
+ const defaultClient = createClient({ provider });
+ const rawClient = createClient({ provider, includeRaw: true });
+
+ const defaultCoupon = await defaultClient.coupons.create({
+ code: 'WELCOME10',
+ discount: { type: 'percentage', value: 10 },
+ });
+ const rawCoupon = await rawClient.coupons.get('promo_123');
+ const checkResult = await defaultClient.coupons.check({
+ code: 'WELCOME10',
+ amount: 1000,
+ currency: 'USD',
+ });
+
+ expect(defaultCoupon.raw).toBeNull();
+ expect(rawCoupon.raw).toMatchObject({ id: 'raw_coupon_123' });
+ expect(checkResult).toMatchObject({
+ valid: true,
+ preview: {
+ subtotal: 1000,
+ discountTotal: 100,
+ total: 900,
+ currency: 'USD',
+ },
+ });
+ });
+
test('delegates client.isSandbox() to the provider', () => {
const client = createClient({
provider: createStubProvider({ sandbox: true }),
@@ -1510,6 +1543,9 @@ function createStubProvider({
onPixCreate,
onPixGet,
onCustomerUpsert,
+ onCouponCreate,
+ onCouponGet,
+ onCouponCheck,
}: {
sandbox?: boolean;
onPaymentCreate?: (
@@ -1537,12 +1573,25 @@ function createStubProvider({
data: Record,
options?: ProviderRequestOptions,
) => Promise>;
+ onCouponCreate?: (
+ data: CouponCreateData,
+ options?: ProviderRequestOptions,
+ ) => Promise>;
+ onCouponGet?: (
+ id: string,
+ options?: ProviderRequestOptions,
+ ) => Promise>;
+ onCouponCheck?: (
+ data: CouponCheckData,
+ options?: ProviderRequestOptions,
+ ) => Promise>;
} = {}) {
return defineProvider({
id: 'stub',
isSandbox: () => sandbox,
capabilities: {
checkout: true,
+ coupons: true,
customers: true,
pix: true,
},
@@ -1565,6 +1614,110 @@ function createStubProvider({
options?.includeRaw,
),
},
+ coupons: {
+ create: onCouponCreate
+ ? onCouponCreate
+ : async (_data, options) =>
+ withRaw(
+ {
+ id: 'promo_123',
+ provider: 'stub',
+ sandbox,
+ code: 'WELCOME10',
+ status: 'active' as const,
+ discount: { type: 'percentage' as const, value: 10 },
+ redemptions: { count: 0, max: null },
+ },
+ { id: 'raw_coupon_123' },
+ options?.includeRaw,
+ ),
+ get: onCouponGet
+ ? onCouponGet
+ : async (_id, options) =>
+ withRaw(
+ {
+ id: 'promo_123',
+ provider: 'stub',
+ sandbox,
+ code: 'WELCOME10',
+ status: 'active' as const,
+ discount: { type: 'percentage' as const, value: 10 },
+ redemptions: { count: 0, max: null },
+ },
+ { id: 'raw_coupon_123' },
+ options?.includeRaw,
+ ),
+ list: async (_options, options) => ({
+ data: [
+ withRaw(
+ {
+ id: 'promo_123',
+ provider: 'stub',
+ sandbox,
+ code: 'WELCOME10',
+ status: 'active' as const,
+ discount: { type: 'percentage' as const, value: 10 },
+ redemptions: { count: 0, max: null },
+ },
+ { id: 'raw_coupon_123' },
+ options?.includeRaw,
+ ),
+ ],
+ total: 1,
+ previous: null,
+ next: null,
+ }),
+ update: async (_id, _data, options) =>
+ withRaw(
+ {
+ id: 'promo_123',
+ provider: 'stub',
+ sandbox,
+ code: 'WELCOME10',
+ status: 'active' as const,
+ discount: { type: 'percentage' as const, value: 10 },
+ redemptions: { count: 0, max: null },
+ },
+ { id: 'raw_coupon_123' },
+ options?.includeRaw,
+ ),
+ delete: async (_id, options) =>
+ withRaw(
+ {
+ id: 'promo_123',
+ provider: 'stub',
+ sandbox,
+ deleted: true,
+ code: 'WELCOME10',
+ },
+ { id: 'raw_coupon_123' },
+ options?.includeRaw,
+ ),
+ check: onCouponCheck
+ ? onCouponCheck
+ : async (_data, options) => ({
+ valid: true,
+ coupon: withRaw(
+ {
+ id: 'promo_123',
+ provider: 'stub',
+ sandbox,
+ code: 'WELCOME10',
+ status: 'active' as const,
+ discount: { type: 'percentage' as const, value: 10 },
+ redemptions: { count: 0, max: null },
+ },
+ { id: 'raw_coupon_123' },
+ options?.includeRaw,
+ ),
+ preview: {
+ subtotal: 1000,
+ discountTotal: 100,
+ total: 900,
+ currency: 'USD',
+ },
+ }),
+ },
pix: {
create: onPixCreate
? onPixCreate
From cb39d3e6fe9fd39b1493b58aeede5da92d7e5a27 Mon Sep 17 00:00:00 2001
From: almeida
Date: Sat, 13 Jun 2026 12:09:30 -0300
Subject: [PATCH 08/15] feat(cli): support coupons migration
---
packages/cli/src/lib/migrations.ts | 57 ++++++++++++++++++++++++++++++
packages/cli/test/cli.test.ts | 2 +-
2 files changed, 58 insertions(+), 1 deletion(-)
diff --git a/packages/cli/src/lib/migrations.ts b/packages/cli/src/lib/migrations.ts
index cd7d7a5..d448850 100644
--- a/packages/cli/src/lib/migrations.ts
+++ b/packages/cli/src/lib/migrations.ts
@@ -69,6 +69,7 @@ const PAYMESH_MIGRATIONS: readonly PaymeshMigrationDefinition[] = [
const BUILT_IN_SANDBOX_TABLES: readonly DatabaseTableKey[] = [
'customers',
+ 'coupons',
'pix',
'checkouts',
'invoices',
@@ -359,6 +360,7 @@ function createInitialMigrationSql(schema: ResolvedDatabaseSchema) {
return [
createMigrationsTableSql(schema),
createCustomersTableSql(schema),
+ createCouponsTableSql(schema),
createPixTableSql(schema),
createCheckoutsTableSql(schema),
createInvoicesTableSql(schema),
@@ -487,6 +489,10 @@ function createIndexesAndConstraintsSql(schema: ResolvedDatabaseSchema) {
createIndexSql(schema, 'customers', ['provider', 'external_id']),
createIndexSql(schema, 'customers', ['provider', 'email']),
createIndexSql(schema, 'customers', ['provider', 'deleted_at']),
+ createIndexSql(schema, 'coupons', ['provider', 'code']),
+ createIndexSql(schema, 'coupons', ['provider', 'status']),
+ createIndexSql(schema, 'coupons', ['provider', 'expires_at']),
+ createIndexSql(schema, 'coupons', ['provider', 'deleted_at']),
createIndexSql(schema, 'pix', ['provider', 'customer_provider_id']),
createIndexSql(schema, 'pix', ['provider', 'status']),
createIndexSql(schema, 'pix', ['provider', 'expires_at']),
@@ -548,6 +554,24 @@ function createIndexesAndConstraintsSql(schema: ResolvedDatabaseSchema) {
'attempts_valid',
'attempts >= 1',
),
+ createCheckConstraintSql(
+ schema,
+ 'coupons',
+ 'discount_value_valid',
+ 'discount_value >= 0',
+ ),
+ createCheckConstraintSql(
+ schema,
+ 'coupons',
+ 'redemption_count_valid',
+ 'redemption_count >= 0',
+ ),
+ createCheckConstraintSql(
+ schema,
+ 'coupons',
+ 'max_redemptions_valid',
+ 'max_redemptions IS NULL OR max_redemptions >= 0',
+ ),
createCheckConstraintSql(
schema,
'pix',
@@ -629,6 +653,39 @@ ${extraTableColumnsSql(schema, 'customers')}
);`.trim();
}
+function createCouponsTableSql(schema: ResolvedDatabaseSchema) {
+ return `
+CREATE TABLE IF NOT EXISTS ${table(schema, 'coupons')} (
+ id BIGSERIAL PRIMARY KEY,
+ provider TEXT NOT NULL,
+ provider_id TEXT NOT NULL,
+ version TEXT NOT NULL DEFAULT 'v1',
+ sandbox BOOLEAN NOT NULL DEFAULT FALSE,
+ code TEXT NOT NULL,
+ name TEXT,
+ status TEXT,
+ active BOOLEAN,
+ customer_provider_id TEXT,
+ discount_type TEXT NOT NULL,
+ discount_value NUMERIC NOT NULL,
+ discount_currency TEXT,
+ starts_at TIMESTAMPTZ,
+ expires_at TIMESTAMPTZ,
+ max_redemptions INTEGER,
+ redemption_count INTEGER NOT NULL DEFAULT 0,
+ minimum_amount BIGINT,
+ minimum_amount_currency TEXT,
+ metadata JSONB,
+ data JSONB NOT NULL DEFAULT '{}'::jsonb,
+ raw JSONB,
+ deleted_at TIMESTAMPTZ,
+${extraTableColumnsSql(schema, 'coupons')}
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (provider, sandbox, provider_id)
+);`.trim();
+}
+
function createPixTableSql(schema: ResolvedDatabaseSchema) {
return `
CREATE TABLE IF NOT EXISTS ${table(schema, 'pix')} (
diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts
index 733e07a..6a21ca1 100644
--- a/packages/cli/test/cli.test.ts
+++ b/packages/cli/test/cli.test.ts
@@ -913,7 +913,7 @@ describe('cli helpers', () => {
const doBlocks = (sandboxMigration?.sql.match(/DO \$\$/g) ?? []).length;
// 11 built-in sandbox tables: customers, pix, checkouts, invoices,
// paymentMethods, entitlements, usage, webhookEvents, subscriptions, products, prices
- expect(doBlocks).toBe(11);
+ expect(doBlocks).toBe(12);
});
test('schema sync migration includes sandbox column and constraint update', async () => {
From ed7fdace03453774d6d9130fe554990c5e0cd111 Mon Sep 17 00:00:00 2001
From: almeida
Date: Sat, 13 Jun 2026 12:10:41 -0300
Subject: [PATCH 09/15] feat(adapters): implement new coupon repository
---
packages/drizzle/src/repositories.ts | 338 ++++++++++++++++++++++++++
packages/memory/src/repositories.ts | 186 ++++++++++++++
packages/memory/src/state.ts | 84 +++++++
packages/memory/src/types.ts | 12 +
packages/postgres/src/repositories.ts | 338 ++++++++++++++++++++++++++
packages/prisma/src/repositories.ts | 338 ++++++++++++++++++++++++++
6 files changed, 1296 insertions(+)
diff --git a/packages/drizzle/src/repositories.ts b/packages/drizzle/src/repositories.ts
index 6b371e6..f75de5e 100644
--- a/packages/drizzle/src/repositories.ts
+++ b/packages/drizzle/src/repositories.ts
@@ -279,6 +279,344 @@ export function createRepositories(
],
}),
},
+ coupons: {
+ async findByProviderId(schema, provider, sandbox, id, options) {
+ const fields = Object.values(schema.tables.coupons.fields);
+ const [row] = await executor.query<
+ {
+ active: boolean | null;
+ code: string;
+ customer_provider_id: string | null;
+ data: Record | null;
+ deleted_at: Date | string | null;
+ discount_currency: string | null;
+ discount_type: string | null;
+ discount_value: number | string | null;
+ expires_at: Date | string | null;
+ max_redemptions: number | string | null;
+ metadata: Record | null;
+ minimum_amount: number | string | null;
+ minimum_amount_currency: string | null;
+ name: string | null;
+ provider: string;
+ provider_id: string;
+ raw: unknown;
+ redemption_count: number | string | null;
+ sandbox: boolean | null;
+ starts_at: Date | string | null;
+ status: string | null;
+ } & Record
+ >({
+ sql: `SELECT provider, provider_id, sandbox, code, name, status, active, customer_provider_id, discount_type, discount_value, discount_currency, starts_at, expires_at, max_redemptions, redemption_count, minimum_amount, minimum_amount_currency, metadata, data, raw, deleted_at${fields.length === 0 ? '' : `, ${fields.map((field) => `${quoteIdentifier(field.column)} AS ${quoteIdentifier(field.key)}`).join(', ')}`}
+ FROM ${tableName(schema, 'coupons')}
+ WHERE provider = $1 AND sandbox = $2 AND provider_id = $3 AND deleted_at IS NULL
+ LIMIT 1`,
+ params: [provider, sandbox, id],
+ });
+
+ if (!row) return null;
+
+ const data = hydrateStoredData(row.data, fields, row);
+
+ return withRaw(
+ {
+ ...data,
+ id: row.provider_id,
+ provider: row.provider,
+ sandbox: row.sandbox ?? false,
+ code: row.code,
+ name: row.name ?? undefined,
+ status: (row.status ?? 'inactive') as never,
+ active: row.active ?? undefined,
+ customerId: row.customer_provider_id ?? undefined,
+ discount: {
+ type: (row.discount_type ?? 'percentage') as
+ | 'percentage'
+ | 'fixed',
+ value: toNullableNumber(row.discount_value) ?? 0,
+ currency: row.discount_currency ?? undefined,
+ },
+ startsAt: toIsoString(row.starts_at) ?? undefined,
+ expiresAt: toIsoString(row.expires_at) ?? undefined,
+ minimumAmount: toNullableNumber(row.minimum_amount) ?? undefined,
+ minimumAmountCurrency: row.minimum_amount_currency ?? undefined,
+ redemptions: {
+ count: toNullableNumber(row.redemption_count) ?? 0,
+ max: toNullableNumber(row.max_redemptions),
+ },
+ metadata: row.metadata ?? undefined,
+ },
+ row.raw,
+ options?.includeRaw,
+ ) as never;
+ },
+ async findByCode(schema, provider, sandbox, code, options) {
+ const fields = Object.values(schema.tables.coupons.fields);
+ const [row] = await executor.query<
+ {
+ active: boolean | null;
+ code: string;
+ customer_provider_id: string | null;
+ data: Record | null;
+ discount_currency: string | null;
+ discount_type: string | null;
+ discount_value: number | string | null;
+ expires_at: Date | string | null;
+ max_redemptions: number | string | null;
+ metadata: Record | null;
+ minimum_amount: number | string | null;
+ minimum_amount_currency: string | null;
+ name: string | null;
+ provider: string;
+ provider_id: string;
+ raw: unknown;
+ redemption_count: number | string | null;
+ sandbox: boolean | null;
+ starts_at: Date | string | null;
+ status: string | null;
+ } & Record
+ >({
+ sql: `SELECT provider, provider_id, sandbox, code, name, status, active, customer_provider_id, discount_type, discount_value, discount_currency, starts_at, expires_at, max_redemptions, redemption_count, minimum_amount, minimum_amount_currency, metadata, data, raw${fields.length === 0 ? '' : `, ${fields.map((field) => `${quoteIdentifier(field.column)} AS ${quoteIdentifier(field.key)}`).join(', ')}`}
+ FROM ${tableName(schema, 'coupons')}
+ WHERE provider = $1 AND sandbox = $2 AND code = $3 AND deleted_at IS NULL
+ LIMIT 1`,
+ params: [provider, sandbox, code],
+ });
+
+ if (!row) return null;
+
+ const data = hydrateStoredData(row.data, fields, row);
+
+ return withRaw(
+ {
+ ...data,
+ id: row.provider_id,
+ provider: row.provider,
+ sandbox: row.sandbox ?? false,
+ code: row.code,
+ name: row.name ?? undefined,
+ status: (row.status ?? 'inactive') as never,
+ active: row.active ?? undefined,
+ customerId: row.customer_provider_id ?? undefined,
+ discount: {
+ type: (row.discount_type ?? 'percentage') as
+ | 'percentage'
+ | 'fixed',
+ value: toNullableNumber(row.discount_value) ?? 0,
+ currency: row.discount_currency ?? undefined,
+ },
+ startsAt: toIsoString(row.starts_at) ?? undefined,
+ expiresAt: toIsoString(row.expires_at) ?? undefined,
+ minimumAmount: toNullableNumber(row.minimum_amount) ?? undefined,
+ minimumAmountCurrency: row.minimum_amount_currency ?? undefined,
+ redemptions: {
+ count: toNullableNumber(row.redemption_count) ?? 0,
+ max: toNullableNumber(row.max_redemptions),
+ },
+ metadata: row.metadata ?? undefined,
+ },
+ row.raw,
+ options?.includeRaw,
+ ) as never;
+ },
+ async list(schema, provider, sandbox, options) {
+ const fields = Object.values(schema.tables.coupons.fields);
+ const limit = options?.limit ?? 20;
+ if (!Number.isInteger(limit) || limit <= 0) {
+ throw new PaymeshError({
+ code: 'invalid_request',
+ message: 'Coupon list limit must be a positive integer',
+ });
+ }
+ if (options?.after && options?.before) {
+ throw new PaymeshError({
+ code: 'invalid_request',
+ message: 'Coupon list accepts either "after" or "before", not both',
+ });
+ }
+
+ const cursor = decodeCustomerCursor(
+ options?.before ?? options?.after,
+ options?.before ? 'before' : 'after',
+ );
+ const includeRaw = options?.includeRaw;
+ const countParams: SqlValue[] = [provider, sandbox];
+ const countFilters: string[] = [
+ 'provider = $1',
+ 'sandbox = $2',
+ 'deleted_at IS NULL',
+ ];
+ if (options?.code) {
+ countParams.push(options.code);
+ countFilters.push(`code = $${countParams.length}`);
+ }
+ if (typeof options?.active === 'boolean') {
+ countParams.push(options.active);
+ countFilters.push(`active = $${countParams.length}`);
+ }
+ const [totalRow] = await executor.query<{ total: number | string }>({
+ sql: `SELECT COUNT(*) AS total FROM ${tableName(schema, 'coupons')} WHERE ${countFilters.join(' AND ')}`,
+ params: countParams,
+ });
+ const params: SqlValue[] = [...countParams];
+ const filters = [...countFilters];
+ let order = 'ASC';
+
+ if (cursor) {
+ params.push(cursor.value.createdAt, cursor.value.providerId);
+ filters.push(
+ cursor.mode === 'before'
+ ? `(created_at, provider_id) < ($${params.length - 1}, $${params.length})`
+ : `(created_at, provider_id) > ($${params.length - 1}, $${params.length})`,
+ );
+ if (cursor.mode === 'before') order = 'DESC';
+ }
+
+ params.push(limit + 1);
+ const rows = await executor.query<
+ {
+ active: boolean | null;
+ code: string;
+ customer_provider_id: string | null;
+ created_at: Date | string;
+ data: Record | null;
+ discount_currency: string | null;
+ discount_type: string | null;
+ discount_value: number | string | null;
+ expires_at: Date | string | null;
+ max_redemptions: number | string | null;
+ metadata: Record | null;
+ minimum_amount: number | string | null;
+ minimum_amount_currency: string | null;
+ name: string | null;
+ provider: string;
+ provider_id: string;
+ raw: unknown;
+ redemption_count: number | string | null;
+ sandbox: boolean | null;
+ starts_at: Date | string | null;
+ status: string | null;
+ } & Record
+ >({
+ sql: `SELECT provider, provider_id, sandbox, created_at, code, name, status, active, customer_provider_id, discount_type, discount_value, discount_currency, starts_at, expires_at, max_redemptions, redemption_count, minimum_amount, minimum_amount_currency, metadata, data, raw${fields.length === 0 ? '' : `, ${fields.map((field) => `${quoteIdentifier(field.column)} AS ${quoteIdentifier(field.key)}`).join(', ')}`}
+ FROM ${tableName(schema, 'coupons')}
+ WHERE ${filters.join(' AND ')}
+ ORDER BY created_at ${order}, provider_id ${order}
+ LIMIT $${params.length}`,
+ params,
+ });
+
+ const hasExtra = rows.length > limit;
+ const windowRows = hasExtra ? rows.slice(0, limit) : rows;
+ const pageRows =
+ cursor?.mode === 'before' ? [...windowRows].reverse() : windowRows;
+ const data = pageRows.map((row) => {
+ const data = hydrateStoredData(row.data, fields, row);
+
+ return withRaw(
+ {
+ ...data,
+ id: row.provider_id,
+ provider: row.provider,
+ sandbox: row.sandbox ?? false,
+ code: row.code,
+ name: row.name ?? undefined,
+ status: (row.status ?? 'inactive') as never,
+ active: row.active ?? undefined,
+ customerId: row.customer_provider_id ?? undefined,
+ discount: {
+ type: (row.discount_type ?? 'percentage') as
+ | 'percentage'
+ | 'fixed',
+ value: toNullableNumber(row.discount_value) ?? 0,
+ currency: row.discount_currency ?? undefined,
+ },
+ startsAt: toIsoString(row.starts_at) ?? undefined,
+ expiresAt: toIsoString(row.expires_at) ?? undefined,
+ minimumAmount: toNullableNumber(row.minimum_amount) ?? undefined,
+ minimumAmountCurrency: row.minimum_amount_currency ?? undefined,
+ redemptions: {
+ count: toNullableNumber(row.redemption_count) ?? 0,
+ max: toNullableNumber(row.max_redemptions),
+ },
+ metadata: row.metadata ?? undefined,
+ },
+ row.raw,
+ includeRaw,
+ ) as never;
+ });
+
+ return {
+ data,
+ total: Number(totalRow?.total ?? 0),
+ previous:
+ data.length === 0
+ ? null
+ : cursor?.mode === 'before'
+ ? hasExtra
+ ? encodeCustomerCursor(pageRows[0]!)
+ : null
+ : cursor
+ ? encodeCustomerCursor(pageRows[0]!)
+ : null,
+ next:
+ data.length === 0
+ ? null
+ : cursor?.mode === 'before'
+ ? encodeCustomerCursor(pageRows[pageRows.length - 1]!)
+ : hasExtra
+ ? encodeCustomerCursor(pageRows[pageRows.length - 1]!)
+ : null,
+ };
+ },
+ upsert: (schema, coupon) =>
+ upsertByProviderId(executor, schema, 'coupons', {
+ provider: coupon.provider,
+ provider_id: coupon.id,
+ version: getVersion(coupon, getInternalRaw(coupon)),
+ sandbox: coupon.sandbox,
+ code: coupon.code,
+ name: coupon.name ?? null,
+ status: coupon.status,
+ active: coupon.active ?? null,
+ customer_provider_id: coupon.customerId ?? null,
+ discount_type: coupon.discount.type,
+ discount_value: coupon.discount.value,
+ discount_currency:
+ coupon.discount.type === 'fixed'
+ ? (coupon.discount.currency ?? null)
+ : null,
+ starts_at: coupon.startsAt ?? null,
+ expires_at: coupon.expiresAt ?? null,
+ max_redemptions: coupon.redemptions.max,
+ redemption_count: coupon.redemptions.count,
+ minimum_amount: coupon.minimumAmount ?? null,
+ minimum_amount_currency: coupon.minimumAmountCurrency ?? null,
+ metadata: coupon.metadata ?? null,
+ data: withoutSchemaFields(
+ withoutRaw(coupon),
+ schema.tables.coupons.fields,
+ ),
+ raw: getPersistableRaw(executor, coupon),
+ deleted_at: null,
+ updated_at: new Date().toISOString(),
+ ...getExtraFieldValues(schema, 'coupons', coupon),
+ }),
+ markDeleted: (schema, coupon) =>
+ executor.execute({
+ sql: `UPDATE ${tableName(schema, 'coupons')}
+ SET version = $4, data = $5, raw = $6, deleted_at = NOW(), updated_at = NOW()
+ WHERE provider = $1 AND sandbox = $2 AND provider_id = $3`,
+ params: [
+ coupon.provider,
+ coupon.sandbox,
+ coupon.id,
+ getVersion(coupon, getInternalRaw(coupon)),
+ withoutRaw(coupon),
+ getPersistableRaw(executor, coupon),
+ ],
+ }),
+ },
pix: {
async findByProviderId(schema, provider, sandbox, id, options) {
const fields = Object.values(schema.tables.pix.fields);
diff --git a/packages/memory/src/repositories.ts b/packages/memory/src/repositories.ts
index 0b86b9f..10d3f9f 100644
--- a/packages/memory/src/repositories.ts
+++ b/packages/memory/src/repositories.ts
@@ -1,6 +1,7 @@
import type {
BaseAnyPayment,
BasePix,
+ PaymeshCouponListOptions,
PaymeshDatabaseRepositories,
PaymeshRepositoryReadOptions,
ResolvedDatabaseSchema,
@@ -235,6 +236,170 @@ export function createRepositories(
});
},
},
+ coupons: {
+ async findByProviderId(_schema, provider, sandbox, id, readOptions) {
+ const coupon = options
+ .getState()
+ .coupons.get(entityKey(provider, sandbox, id));
+ if (!coupon || coupon.deletedAt) return null;
+
+ return mapStoredCoupon(coupon, readOptions) as never;
+ },
+ async findByCode(_schema, provider, sandbox, code, readOptions) {
+ const coupon = [...options.getState().coupons.values()].find(
+ (row) =>
+ row.provider === provider &&
+ row.sandbox === sandbox &&
+ row.deletedAt === null &&
+ row.code === code,
+ );
+ if (!coupon) return null;
+
+ return mapStoredCoupon(coupon, readOptions) as never;
+ },
+ async upsert(schema, coupon) {
+ validateRequiredString(coupon.id, 'id', schema.tables.coupons.name);
+ validateRequiredString(
+ coupon.provider,
+ 'provider',
+ schema.tables.coupons.name,
+ );
+ validateRequiredBoolean(
+ coupon.sandbox,
+ 'sandbox',
+ schema.tables.coupons.name,
+ );
+ validateRequiredString(coupon.code, 'code', schema.tables.coupons.name);
+
+ const prepared = applyTableFieldDefaults(
+ schema.tables.coupons,
+ coupon as Record,
+ );
+ validateRequiredTableFields(schema, 'coupons', prepared);
+ const couponRecord = prepared as Record;
+
+ const key = entityKey(
+ couponRecord.provider as string,
+ couponRecord.sandbox as boolean,
+ couponRecord.id as string,
+ );
+ const existing = options.getState().coupons.get(key);
+ const createdAt = existing?.createdAt ?? new Date().toISOString();
+ options.getState().coupons.set(key, {
+ ...stripTableFieldKeys(withoutRaw(couponRecord), schema, 'coupons'),
+ ...couponRecord,
+ createdAt,
+ updatedAt: new Date().toISOString(),
+ deletedAt: null,
+ raw: getPersistableRaw(options.persistRaw, coupon),
+ version: getVersion(coupon, getPersistableRaw(true, coupon)),
+ } as never);
+ },
+ async list(_schema, provider, sandbox, listOptions) {
+ const limit = listOptions?.limit ?? 20;
+ if (!Number.isInteger(limit) || limit <= 0) {
+ throw new PaymeshError({
+ code: 'invalid_request',
+ message: 'Coupon list limit must be a positive integer',
+ });
+ }
+ if (listOptions?.after && listOptions?.before) {
+ throw new PaymeshError({
+ code: 'invalid_request',
+ message: 'Coupon list accepts either "after" or "before", not both',
+ });
+ }
+
+ const cursor = decodeCustomerCursor(
+ listOptions?.before ?? listOptions?.after,
+ listOptions?.before ? 'before' : 'after',
+ );
+ const filtered = [...options.getState().coupons.values()]
+ .filter(
+ (coupon) =>
+ coupon.provider === provider &&
+ coupon.sandbox === sandbox &&
+ coupon.deletedAt === null &&
+ (listOptions?.code ? coupon.code === listOptions.code : true) &&
+ (typeof listOptions?.active === 'boolean'
+ ? coupon.active === listOptions.active
+ : true),
+ )
+ .sort(compareCustomerRows);
+
+ let pageSource = filtered;
+ if (cursor?.mode === 'after') {
+ pageSource = filtered.filter(
+ (coupon) => compareCustomerRowToCursor(coupon, cursor.value) > 0,
+ );
+ } else if (cursor?.mode === 'before') {
+ pageSource = filtered
+ .filter(
+ (coupon) => compareCustomerRowToCursor(coupon, cursor.value) < 0,
+ )
+ .sort((left, right) => compareCustomerRows(right, left));
+ }
+
+ const hasExtra = pageSource.length > limit;
+ const windowRows = hasExtra ? pageSource.slice(0, limit) : pageSource;
+ const pageRows =
+ cursor?.mode === 'before' ? [...windowRows].reverse() : windowRows;
+ const data = pageRows.map((coupon) =>
+ mapStoredCoupon(coupon, listOptions),
+ );
+
+ return {
+ data,
+ total: filtered.length,
+ previous:
+ data.length === 0
+ ? null
+ : cursor?.mode === 'before'
+ ? hasExtra
+ ? encodeCustomerCursor({
+ createdAt: pageRows[0]!.createdAt,
+ providerId: pageRows[0]!.id,
+ })
+ : null
+ : cursor
+ ? encodeCustomerCursor({
+ createdAt: pageRows[0]!.createdAt,
+ providerId: pageRows[0]!.id,
+ })
+ : null,
+ next:
+ data.length === 0
+ ? null
+ : cursor?.mode === 'before'
+ ? encodeCustomerCursor({
+ createdAt: pageRows[pageRows.length - 1]!.createdAt,
+ providerId: pageRows[pageRows.length - 1]!.id,
+ })
+ : hasExtra
+ ? encodeCustomerCursor({
+ createdAt: pageRows[pageRows.length - 1]!.createdAt,
+ providerId: pageRows[pageRows.length - 1]!.id,
+ })
+ : null,
+ } as never;
+ },
+ async markDeleted(_schema, coupon) {
+ const key = entityKey(coupon.provider, coupon.sandbox, coupon.id);
+ const existing = options.getState().coupons.get(key);
+ options.getState().coupons.set(key, {
+ id: coupon.id,
+ code: existing?.code ?? coupon.code ?? coupon.id,
+ provider: coupon.provider,
+ sandbox: coupon.sandbox,
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ deletedAt: new Date().toISOString(),
+ raw: getPersistableRaw(options.persistRaw, coupon),
+ deleted: true,
+ version: getVersion(coupon, getPersistableRaw(true, coupon)),
+ });
+ },
+ },
pix: {
async findByProviderId(_schema, provider, sandbox, id, readOptions) {
const pix = options
@@ -561,6 +726,27 @@ function mapStoredCustomer(
return withRaw(cloneValue(data), raw, readOptions?.includeRaw) as never;
}
+function mapStoredCoupon(
+ coupon: Record & {
+ id: string;
+ provider: string;
+ sandbox: boolean;
+ raw: unknown;
+ },
+ readOptions?:
+ | PaymeshCouponListOptions
+ | PaymeshRepositoryReadOptions,
+) {
+ const {
+ raw,
+ createdAt: _createdAt,
+ updatedAt: _updatedAt,
+ deletedAt: _deletedAt,
+ ...data
+ } = coupon;
+ return withRaw(cloneValue(data), raw, readOptions?.includeRaw) as never;
+}
+
function mapStoredPayment(
payment: Record & {
raw: unknown;
diff --git a/packages/memory/src/state.ts b/packages/memory/src/state.ts
index 0d32ff7..ccd57f0 100644
--- a/packages/memory/src/state.ts
+++ b/packages/memory/src/state.ts
@@ -9,6 +9,7 @@ import {
} from './shared/schema';
import type {
MemoryDatabaseSeed,
+ MemorySeedCoupon,
MemorySeedCustomer,
MemorySeedPayment,
MemorySeedPix,
@@ -42,6 +43,26 @@ export interface StoredCustomer extends Record {
raw: unknown;
}
+/** Shape of a coupon record as stored in the in-memory database. */
+export interface StoredCoupon extends Record {
+ /** Unique coupon identifier assigned by the provider. */
+ id: string;
+ /** Provider identifier that owns this coupon. */
+ provider: string;
+ /** Whether this record belongs to the sandbox environment. */
+ sandbox: boolean;
+ /** Coupon code shown to end users. */
+ code: string;
+ /** ISO-8601 creation timestamp. */
+ createdAt: string;
+ /** ISO-8601 last update timestamp. */
+ updatedAt: string;
+ /** ISO-8601 soft-deletion timestamp, or `null` if active. */
+ deletedAt: string | null;
+ /** Persisted raw provider payload, or `null` when `persistRaw` is disabled. */
+ raw: unknown;
+}
+
/** Shape of a payment record (pix, checkout, or invoice) as stored in memory. */
export interface StoredPayment extends Record {
/** Unique payment identifier assigned by the provider. */
@@ -137,6 +158,8 @@ export interface StoredCatalogRecord extends Record {
export interface MemoryDatabaseState {
/** Customer records indexed by composite entity key. */
customers: Map;
+ /** Coupon records indexed by composite entity key. */
+ coupons: Map;
/** Pix payment records indexed by composite entity key. */
pix: Map;
/** Checkout payment records indexed by composite entity key. */
@@ -159,6 +182,7 @@ export interface MemoryDatabaseState {
export function createEmptyState(): MemoryDatabaseState {
return {
customers: new Map(),
+ coupons: new Map(),
pix: new Map(),
checkouts: new Map(),
invoices: new Map(),
@@ -179,6 +203,7 @@ export function createEmptyState(): MemoryDatabaseState {
export function cloneState(state: MemoryDatabaseState): MemoryDatabaseState {
return {
customers: cloneMap(state.customers),
+ coupons: cloneMap(state.coupons),
pix: cloneMap(state.pix),
checkouts: cloneMap(state.checkouts),
invoices: cloneMap(state.invoices),
@@ -359,6 +384,10 @@ export function applySeed(
insertSeedCustomer(state, schema, customer, strict, persistRaw);
}
+ for (const coupon of seed.coupons ?? []) {
+ insertSeedCoupon(state, schema, coupon, strict, persistRaw);
+ }
+
for (const product of seed.products ?? []) {
insertSeedProduct(state, schema, product, strict, persistRaw);
}
@@ -455,6 +484,61 @@ function insertSeedCustomer(
} as never);
}
+function insertSeedCoupon(
+ state: MemoryDatabaseState,
+ schema: ResolvedDatabaseSchema,
+ coupon: MemorySeedCoupon,
+ strict: boolean,
+ persistRaw: boolean,
+) {
+ validateRequiredString(coupon.id, 'id', schema.tables.coupons.name);
+ validateRequiredString(
+ coupon.provider,
+ 'provider',
+ schema.tables.coupons.name,
+ );
+ validateRequiredBoolean(
+ coupon.sandbox,
+ 'sandbox',
+ schema.tables.coupons.name,
+ );
+ validateRequiredString(coupon.code, 'code', schema.tables.coupons.name);
+
+ const next = applyTableFieldDefaults(
+ schema.tables.coupons,
+ coupon as unknown as Record,
+ );
+ validateRequiredTableFields(schema, 'coupons', next);
+ const couponRecord = next as Record;
+
+ const key = entityKey(
+ couponRecord.provider as string,
+ couponRecord.sandbox as boolean,
+ couponRecord.id as string,
+ );
+ validateUniqueInsert(
+ strict,
+ state.coupons.has(key),
+ schema.tables.coupons.name,
+ couponRecord.id as string,
+ );
+ const now =
+ typeof couponRecord.createdAt === 'string'
+ ? couponRecord.createdAt
+ : new Date().toISOString();
+ state.coupons.set(key, {
+ ...stripTableFieldKeys(couponRecord, schema, 'coupons'),
+ ...couponRecord,
+ createdAt: now,
+ updatedAt:
+ typeof couponRecord.updatedAt === 'string' ? couponRecord.updatedAt : now,
+ deletedAt: couponRecord.deleted ? now : null,
+ raw: persistRaw
+ ? (couponRecord.raw ?? getInternalRaw(couponRecord) ?? null)
+ : null,
+ } as never);
+}
+
function insertSeedPayment(
state: MemoryDatabaseState,
schema: ResolvedDatabaseSchema,
diff --git a/packages/memory/src/types.ts b/packages/memory/src/types.ts
index 265b9ca..d731818 100644
--- a/packages/memory/src/types.ts
+++ b/packages/memory/src/types.ts
@@ -1,5 +1,6 @@
import type {
BaseAnyPayment,
+ BaseCoupon,
BaseCustomer,
BasePaymeshEvent,
BasePix,
@@ -24,6 +25,15 @@ export type MemorySeedCustomer = BaseCustomer &
raw?: unknown;
};
+/** Coupon record used for seeding the in-memory database. */
+export type MemorySeedCoupon = BaseCoupon &
+ MemorySeedTimestamps & {
+ /** Marks the coupon as soft-deleted at seed time. */
+ deleted?: boolean;
+ /** Optional raw provider payload to persist when `persistRaw` is enabled. */
+ raw?: unknown;
+ };
+
/** Payment record (checkout or invoice) used for seeding the in-memory database. */
export type MemorySeedPayment = BaseAnyPayment &
MemorySeedTimestamps & {
@@ -91,6 +101,8 @@ export type MemorySeedPrice = ProviderCatalogPrice &
export interface MemoryDatabaseSeed {
/** Customer records to seed. */
customers?: MemorySeedCustomer[];
+ /** Coupon records to seed. */
+ coupons?: MemorySeedCoupon[];
/** Pix payment records to seed. */
pix?: MemorySeedPix[];
/** Checkout payment records to seed. */
diff --git a/packages/postgres/src/repositories.ts b/packages/postgres/src/repositories.ts
index 0257aa2..6899a64 100644
--- a/packages/postgres/src/repositories.ts
+++ b/packages/postgres/src/repositories.ts
@@ -279,6 +279,344 @@ export function createRepositories(
],
}),
},
+ coupons: {
+ async findByProviderId(schema, provider, sandbox, id, options) {
+ const fields = Object.values(schema.tables.coupons.fields);
+ const [row] = await executor.query<
+ {
+ active: boolean | null;
+ code: string;
+ customer_provider_id: string | null;
+ data: Record | null;
+ deleted_at: Date | string | null;
+ discount_currency: string | null;
+ discount_type: string | null;
+ discount_value: number | string | null;
+ expires_at: Date | string | null;
+ max_redemptions: number | string | null;
+ metadata: Record | null;
+ minimum_amount: number | string | null;
+ minimum_amount_currency: string | null;
+ name: string | null;
+ provider: string;
+ provider_id: string;
+ raw: unknown;
+ redemption_count: number | string | null;
+ sandbox: boolean | null;
+ starts_at: Date | string | null;
+ status: string | null;
+ } & Record
+ >({
+ sql: `SELECT provider, provider_id, sandbox, code, name, status, active, customer_provider_id, discount_type, discount_value, discount_currency, starts_at, expires_at, max_redemptions, redemption_count, minimum_amount, minimum_amount_currency, metadata, data, raw, deleted_at${fields.length === 0 ? '' : `, ${fields.map((field) => `${quoteIdentifier(field.column)} AS ${quoteIdentifier(field.key)}`).join(', ')}`}
+ FROM ${tableName(schema, 'coupons')}
+ WHERE provider = $1 AND sandbox = $2 AND provider_id = $3 AND deleted_at IS NULL
+ LIMIT 1`,
+ params: [provider, sandbox, id],
+ });
+
+ if (!row) return null;
+
+ const data = hydrateStoredData(row.data, fields, row);
+
+ return withRaw(
+ {
+ ...data,
+ id: row.provider_id,
+ provider: row.provider,
+ sandbox: row.sandbox ?? false,
+ code: row.code,
+ name: row.name ?? undefined,
+ status: (row.status ?? 'inactive') as never,
+ active: row.active ?? undefined,
+ customerId: row.customer_provider_id ?? undefined,
+ discount: {
+ type: (row.discount_type ?? 'percentage') as
+ | 'percentage'
+ | 'fixed',
+ value: toNullableNumber(row.discount_value) ?? 0,
+ currency: row.discount_currency ?? undefined,
+ },
+ startsAt: toIsoString(row.starts_at) ?? undefined,
+ expiresAt: toIsoString(row.expires_at) ?? undefined,
+ minimumAmount: toNullableNumber(row.minimum_amount) ?? undefined,
+ minimumAmountCurrency: row.minimum_amount_currency ?? undefined,
+ redemptions: {
+ count: toNullableNumber(row.redemption_count) ?? 0,
+ max: toNullableNumber(row.max_redemptions),
+ },
+ metadata: row.metadata ?? undefined,
+ },
+ row.raw,
+ options?.includeRaw,
+ ) as never;
+ },
+ async findByCode(schema, provider, sandbox, code, options) {
+ const fields = Object.values(schema.tables.coupons.fields);
+ const [row] = await executor.query<
+ {
+ active: boolean | null;
+ code: string;
+ customer_provider_id: string | null;
+ data: Record | null;
+ discount_currency: string | null;
+ discount_type: string | null;
+ discount_value: number | string | null;
+ expires_at: Date | string | null;
+ max_redemptions: number | string | null;
+ metadata: Record | null;
+ minimum_amount: number | string | null;
+ minimum_amount_currency: string | null;
+ name: string | null;
+ provider: string;
+ provider_id: string;
+ raw: unknown;
+ redemption_count: number | string | null;
+ sandbox: boolean | null;
+ starts_at: Date | string | null;
+ status: string | null;
+ } & Record
+ >({
+ sql: `SELECT provider, provider_id, sandbox, code, name, status, active, customer_provider_id, discount_type, discount_value, discount_currency, starts_at, expires_at, max_redemptions, redemption_count, minimum_amount, minimum_amount_currency, metadata, data, raw${fields.length === 0 ? '' : `, ${fields.map((field) => `${quoteIdentifier(field.column)} AS ${quoteIdentifier(field.key)}`).join(', ')}`}
+ FROM ${tableName(schema, 'coupons')}
+ WHERE provider = $1 AND sandbox = $2 AND code = $3 AND deleted_at IS NULL
+ LIMIT 1`,
+ params: [provider, sandbox, code],
+ });
+
+ if (!row) return null;
+
+ const data = hydrateStoredData(row.data, fields, row);
+
+ return withRaw(
+ {
+ ...data,
+ id: row.provider_id,
+ provider: row.provider,
+ sandbox: row.sandbox ?? false,
+ code: row.code,
+ name: row.name ?? undefined,
+ status: (row.status ?? 'inactive') as never,
+ active: row.active ?? undefined,
+ customerId: row.customer_provider_id ?? undefined,
+ discount: {
+ type: (row.discount_type ?? 'percentage') as
+ | 'percentage'
+ | 'fixed',
+ value: toNullableNumber(row.discount_value) ?? 0,
+ currency: row.discount_currency ?? undefined,
+ },
+ startsAt: toIsoString(row.starts_at) ?? undefined,
+ expiresAt: toIsoString(row.expires_at) ?? undefined,
+ minimumAmount: toNullableNumber(row.minimum_amount) ?? undefined,
+ minimumAmountCurrency: row.minimum_amount_currency ?? undefined,
+ redemptions: {
+ count: toNullableNumber(row.redemption_count) ?? 0,
+ max: toNullableNumber(row.max_redemptions),
+ },
+ metadata: row.metadata ?? undefined,
+ },
+ row.raw,
+ options?.includeRaw,
+ ) as never;
+ },
+ async list(schema, provider, sandbox, options) {
+ const fields = Object.values(schema.tables.coupons.fields);
+ const limit = options?.limit ?? 20;
+ if (!Number.isInteger(limit) || limit <= 0) {
+ throw new PaymeshError({
+ code: 'invalid_request',
+ message: 'Coupon list limit must be a positive integer',
+ });
+ }
+ if (options?.after && options?.before) {
+ throw new PaymeshError({
+ code: 'invalid_request',
+ message: 'Coupon list accepts either "after" or "before", not both',
+ });
+ }
+
+ const cursor = decodeCustomerCursor(
+ options?.before ?? options?.after,
+ options?.before ? 'before' : 'after',
+ );
+ const includeRaw = options?.includeRaw;
+ const countParams: SqlValue[] = [provider, sandbox];
+ const countFilters: string[] = [
+ 'provider = $1',
+ 'sandbox = $2',
+ 'deleted_at IS NULL',
+ ];
+ if (options?.code) {
+ countParams.push(options.code);
+ countFilters.push(`code = $${countParams.length}`);
+ }
+ if (typeof options?.active === 'boolean') {
+ countParams.push(options.active);
+ countFilters.push(`active = $${countParams.length}`);
+ }
+ const [totalRow] = await executor.query<{ total: number | string }>({
+ sql: `SELECT COUNT(*) AS total FROM ${tableName(schema, 'coupons')} WHERE ${countFilters.join(' AND ')}`,
+ params: countParams,
+ });
+ const params: SqlValue[] = [...countParams];
+ const filters = [...countFilters];
+ let order = 'ASC';
+
+ if (cursor) {
+ params.push(cursor.value.createdAt, cursor.value.providerId);
+ filters.push(
+ cursor.mode === 'before'
+ ? `(created_at, provider_id) < ($${params.length - 1}, $${params.length})`
+ : `(created_at, provider_id) > ($${params.length - 1}, $${params.length})`,
+ );
+ if (cursor.mode === 'before') order = 'DESC';
+ }
+
+ params.push(limit + 1);
+ const rows = await executor.query<
+ {
+ active: boolean | null;
+ code: string;
+ customer_provider_id: string | null;
+ created_at: Date | string;
+ data: Record | null;
+ discount_currency: string | null;
+ discount_type: string | null;
+ discount_value: number | string | null;
+ expires_at: Date | string | null;
+ max_redemptions: number | string | null;
+ metadata: Record | null;
+ minimum_amount: number | string | null;
+ minimum_amount_currency: string | null;
+ name: string | null;
+ provider: string;
+ provider_id: string;
+ raw: unknown;
+ redemption_count: number | string | null;
+ sandbox: boolean | null;
+ starts_at: Date | string | null;
+ status: string | null;
+ } & Record
+ >({
+ sql: `SELECT provider, provider_id, sandbox, created_at, code, name, status, active, customer_provider_id, discount_type, discount_value, discount_currency, starts_at, expires_at, max_redemptions, redemption_count, minimum_amount, minimum_amount_currency, metadata, data, raw${fields.length === 0 ? '' : `, ${fields.map((field) => `${quoteIdentifier(field.column)} AS ${quoteIdentifier(field.key)}`).join(', ')}`}
+ FROM ${tableName(schema, 'coupons')}
+ WHERE ${filters.join(' AND ')}
+ ORDER BY created_at ${order}, provider_id ${order}
+ LIMIT $${params.length}`,
+ params,
+ });
+
+ const hasExtra = rows.length > limit;
+ const windowRows = hasExtra ? rows.slice(0, limit) : rows;
+ const pageRows =
+ cursor?.mode === 'before' ? [...windowRows].reverse() : windowRows;
+ const data = pageRows.map((row) => {
+ const data = hydrateStoredData(row.data, fields, row);
+
+ return withRaw(
+ {
+ ...data,
+ id: row.provider_id,
+ provider: row.provider,
+ sandbox: row.sandbox ?? false,
+ code: row.code,
+ name: row.name ?? undefined,
+ status: (row.status ?? 'inactive') as never,
+ active: row.active ?? undefined,
+ customerId: row.customer_provider_id ?? undefined,
+ discount: {
+ type: (row.discount_type ?? 'percentage') as
+ | 'percentage'
+ | 'fixed',
+ value: toNullableNumber(row.discount_value) ?? 0,
+ currency: row.discount_currency ?? undefined,
+ },
+ startsAt: toIsoString(row.starts_at) ?? undefined,
+ expiresAt: toIsoString(row.expires_at) ?? undefined,
+ minimumAmount: toNullableNumber(row.minimum_amount) ?? undefined,
+ minimumAmountCurrency: row.minimum_amount_currency ?? undefined,
+ redemptions: {
+ count: toNullableNumber(row.redemption_count) ?? 0,
+ max: toNullableNumber(row.max_redemptions),
+ },
+ metadata: row.metadata ?? undefined,
+ },
+ row.raw,
+ includeRaw,
+ ) as never;
+ });
+
+ return {
+ data,
+ total: Number(totalRow?.total ?? 0),
+ previous:
+ data.length === 0
+ ? null
+ : cursor?.mode === 'before'
+ ? hasExtra
+ ? encodeCustomerCursor(pageRows[0]!)
+ : null
+ : cursor
+ ? encodeCustomerCursor(pageRows[0]!)
+ : null,
+ next:
+ data.length === 0
+ ? null
+ : cursor?.mode === 'before'
+ ? encodeCustomerCursor(pageRows[pageRows.length - 1]!)
+ : hasExtra
+ ? encodeCustomerCursor(pageRows[pageRows.length - 1]!)
+ : null,
+ };
+ },
+ upsert: (schema, coupon) =>
+ upsertByProviderId(executor, schema, 'coupons', {
+ provider: coupon.provider,
+ provider_id: coupon.id,
+ version: getVersion(coupon, getInternalRaw(coupon)),
+ sandbox: coupon.sandbox,
+ code: coupon.code,
+ name: coupon.name ?? null,
+ status: coupon.status,
+ active: coupon.active ?? null,
+ customer_provider_id: coupon.customerId ?? null,
+ discount_type: coupon.discount.type,
+ discount_value: coupon.discount.value,
+ discount_currency:
+ coupon.discount.type === 'fixed'
+ ? (coupon.discount.currency ?? null)
+ : null,
+ starts_at: coupon.startsAt ?? null,
+ expires_at: coupon.expiresAt ?? null,
+ max_redemptions: coupon.redemptions.max,
+ redemption_count: coupon.redemptions.count,
+ minimum_amount: coupon.minimumAmount ?? null,
+ minimum_amount_currency: coupon.minimumAmountCurrency ?? null,
+ metadata: coupon.metadata ?? null,
+ data: withoutSchemaFields(
+ withoutRaw(coupon),
+ schema.tables.coupons.fields,
+ ),
+ raw: getPersistableRaw(executor, coupon),
+ deleted_at: null,
+ updated_at: new Date().toISOString(),
+ ...getExtraFieldValues(schema, 'coupons', coupon),
+ }),
+ markDeleted: (schema, coupon) =>
+ executor.execute({
+ sql: `UPDATE ${tableName(schema, 'coupons')}
+ SET version = $4, data = $5, raw = $6, deleted_at = NOW(), updated_at = NOW()
+ WHERE provider = $1 AND sandbox = $2 AND provider_id = $3`,
+ params: [
+ coupon.provider,
+ coupon.sandbox,
+ coupon.id,
+ getVersion(coupon, getInternalRaw(coupon)),
+ withoutRaw(coupon),
+ getPersistableRaw(executor, coupon),
+ ],
+ }),
+ },
pix: {
async findByProviderId(schema, provider, sandbox, id, options) {
const fields = Object.values(schema.tables.pix.fields);
diff --git a/packages/prisma/src/repositories.ts b/packages/prisma/src/repositories.ts
index af5828e..6f4080e 100644
--- a/packages/prisma/src/repositories.ts
+++ b/packages/prisma/src/repositories.ts
@@ -279,6 +279,344 @@ export function createRepositories(
],
}),
},
+ coupons: {
+ async findByProviderId(schema, provider, sandbox, id, options) {
+ const fields = Object.values(schema.tables.coupons.fields);
+ const [row] = await executor.query<
+ {
+ active: boolean | null;
+ code: string;
+ customer_provider_id: string | null;
+ data: Record | null;
+ deleted_at: Date | string | null;
+ discount_currency: string | null;
+ discount_type: string | null;
+ discount_value: number | string | null;
+ expires_at: Date | string | null;
+ max_redemptions: number | string | null;
+ metadata: Record | null;
+ minimum_amount: number | string | null;
+ minimum_amount_currency: string | null;
+ name: string | null;
+ provider: string;
+ provider_id: string;
+ raw: unknown;
+ redemption_count: number | string | null;
+ sandbox: boolean | null;
+ starts_at: Date | string | null;
+ status: string | null;
+ } & Record
+ >({
+ sql: `SELECT provider, provider_id, sandbox, code, name, status, active, customer_provider_id, discount_type, discount_value, discount_currency, starts_at, expires_at, max_redemptions, redemption_count, minimum_amount, minimum_amount_currency, metadata, data, raw, deleted_at${fields.length === 0 ? '' : `, ${fields.map((field) => `${quoteIdentifier(field.column)} AS ${quoteIdentifier(field.key)}`).join(', ')}`}
+ FROM ${tableName(schema, 'coupons')}
+ WHERE provider = $1 AND sandbox = $2 AND provider_id = $3 AND deleted_at IS NULL
+ LIMIT 1`,
+ params: [provider, sandbox, id],
+ });
+
+ if (!row) return null;
+
+ const data = hydrateStoredData(row.data, fields, row);
+
+ return withRaw(
+ {
+ ...data,
+ id: row.provider_id,
+ provider: row.provider,
+ sandbox: row.sandbox ?? false,
+ code: row.code,
+ name: row.name ?? undefined,
+ status: (row.status ?? 'inactive') as never,
+ active: row.active ?? undefined,
+ customerId: row.customer_provider_id ?? undefined,
+ discount: {
+ type: (row.discount_type ?? 'percentage') as
+ | 'percentage'
+ | 'fixed',
+ value: toNullableNumber(row.discount_value) ?? 0,
+ currency: row.discount_currency ?? undefined,
+ },
+ startsAt: toIsoString(row.starts_at) ?? undefined,
+ expiresAt: toIsoString(row.expires_at) ?? undefined,
+ minimumAmount: toNullableNumber(row.minimum_amount) ?? undefined,
+ minimumAmountCurrency: row.minimum_amount_currency ?? undefined,
+ redemptions: {
+ count: toNullableNumber(row.redemption_count) ?? 0,
+ max: toNullableNumber(row.max_redemptions),
+ },
+ metadata: row.metadata ?? undefined,
+ },
+ row.raw,
+ options?.includeRaw,
+ ) as never;
+ },
+ async findByCode(schema, provider, sandbox, code, options) {
+ const fields = Object.values(schema.tables.coupons.fields);
+ const [row] = await executor.query<
+ {
+ active: boolean | null;
+ code: string;
+ customer_provider_id: string | null;
+ data: Record | null;
+ discount_currency: string | null;
+ discount_type: string | null;
+ discount_value: number | string | null;
+ expires_at: Date | string | null;
+ max_redemptions: number | string | null;
+ metadata: Record | null;
+ minimum_amount: number | string | null;
+ minimum_amount_currency: string | null;
+ name: string | null;
+ provider: string;
+ provider_id: string;
+ raw: unknown;
+ redemption_count: number | string | null;
+ sandbox: boolean | null;
+ starts_at: Date | string | null;
+ status: string | null;
+ } & Record
+ >({
+ sql: `SELECT provider, provider_id, sandbox, code, name, status, active, customer_provider_id, discount_type, discount_value, discount_currency, starts_at, expires_at, max_redemptions, redemption_count, minimum_amount, minimum_amount_currency, metadata, data, raw${fields.length === 0 ? '' : `, ${fields.map((field) => `${quoteIdentifier(field.column)} AS ${quoteIdentifier(field.key)}`).join(', ')}`}
+ FROM ${tableName(schema, 'coupons')}
+ WHERE provider = $1 AND sandbox = $2 AND code = $3 AND deleted_at IS NULL
+ LIMIT 1`,
+ params: [provider, sandbox, code],
+ });
+
+ if (!row) return null;
+
+ const data = hydrateStoredData(row.data, fields, row);
+
+ return withRaw(
+ {
+ ...data,
+ id: row.provider_id,
+ provider: row.provider,
+ sandbox: row.sandbox ?? false,
+ code: row.code,
+ name: row.name ?? undefined,
+ status: (row.status ?? 'inactive') as never,
+ active: row.active ?? undefined,
+ customerId: row.customer_provider_id ?? undefined,
+ discount: {
+ type: (row.discount_type ?? 'percentage') as
+ | 'percentage'
+ | 'fixed',
+ value: toNullableNumber(row.discount_value) ?? 0,
+ currency: row.discount_currency ?? undefined,
+ },
+ startsAt: toIsoString(row.starts_at) ?? undefined,
+ expiresAt: toIsoString(row.expires_at) ?? undefined,
+ minimumAmount: toNullableNumber(row.minimum_amount) ?? undefined,
+ minimumAmountCurrency: row.minimum_amount_currency ?? undefined,
+ redemptions: {
+ count: toNullableNumber(row.redemption_count) ?? 0,
+ max: toNullableNumber(row.max_redemptions),
+ },
+ metadata: row.metadata ?? undefined,
+ },
+ row.raw,
+ options?.includeRaw,
+ ) as never;
+ },
+ async list(schema, provider, sandbox, options) {
+ const fields = Object.values(schema.tables.coupons.fields);
+ const limit = options?.limit ?? 20;
+ if (!Number.isInteger(limit) || limit <= 0) {
+ throw new PaymeshError({
+ code: 'invalid_request',
+ message: 'Coupon list limit must be a positive integer',
+ });
+ }
+ if (options?.after && options?.before) {
+ throw new PaymeshError({
+ code: 'invalid_request',
+ message: 'Coupon list accepts either "after" or "before", not both',
+ });
+ }
+
+ const cursor = decodeCustomerCursor(
+ options?.before ?? options?.after,
+ options?.before ? 'before' : 'after',
+ );
+ const includeRaw = options?.includeRaw;
+ const countParams: SqlValue[] = [provider, sandbox];
+ const countFilters: string[] = [
+ 'provider = $1',
+ 'sandbox = $2',
+ 'deleted_at IS NULL',
+ ];
+ if (options?.code) {
+ countParams.push(options.code);
+ countFilters.push(`code = $${countParams.length}`);
+ }
+ if (typeof options?.active === 'boolean') {
+ countParams.push(options.active);
+ countFilters.push(`active = $${countParams.length}`);
+ }
+ const [totalRow] = await executor.query<{ total: number | string }>({
+ sql: `SELECT COUNT(*) AS total FROM ${tableName(schema, 'coupons')} WHERE ${countFilters.join(' AND ')}`,
+ params: countParams,
+ });
+ const params: SqlValue[] = [...countParams];
+ const filters = [...countFilters];
+ let order = 'ASC';
+
+ if (cursor) {
+ params.push(cursor.value.createdAt, cursor.value.providerId);
+ filters.push(
+ cursor.mode === 'before'
+ ? `(created_at, provider_id) < ($${params.length - 1}, $${params.length})`
+ : `(created_at, provider_id) > ($${params.length - 1}, $${params.length})`,
+ );
+ if (cursor.mode === 'before') order = 'DESC';
+ }
+
+ params.push(limit + 1);
+ const rows = await executor.query<
+ {
+ active: boolean | null;
+ code: string;
+ customer_provider_id: string | null;
+ created_at: Date | string;
+ data: Record | null;
+ discount_currency: string | null;
+ discount_type: string | null;
+ discount_value: number | string | null;
+ expires_at: Date | string | null;
+ max_redemptions: number | string | null;
+ metadata: Record | null;
+ minimum_amount: number | string | null;
+ minimum_amount_currency: string | null;
+ name: string | null;
+ provider: string;
+ provider_id: string;
+ raw: unknown;
+ redemption_count: number | string | null;
+ sandbox: boolean | null;
+ starts_at: Date | string | null;
+ status: string | null;
+ } & Record
+ >({
+ sql: `SELECT provider, provider_id, sandbox, created_at, code, name, status, active, customer_provider_id, discount_type, discount_value, discount_currency, starts_at, expires_at, max_redemptions, redemption_count, minimum_amount, minimum_amount_currency, metadata, data, raw${fields.length === 0 ? '' : `, ${fields.map((field) => `${quoteIdentifier(field.column)} AS ${quoteIdentifier(field.key)}`).join(', ')}`}
+ FROM ${tableName(schema, 'coupons')}
+ WHERE ${filters.join(' AND ')}
+ ORDER BY created_at ${order}, provider_id ${order}
+ LIMIT $${params.length}`,
+ params,
+ });
+
+ const hasExtra = rows.length > limit;
+ const windowRows = hasExtra ? rows.slice(0, limit) : rows;
+ const pageRows =
+ cursor?.mode === 'before' ? [...windowRows].reverse() : windowRows;
+ const data = pageRows.map((row) => {
+ const data = hydrateStoredData(row.data, fields, row);
+
+ return withRaw(
+ {
+ ...data,
+ id: row.provider_id,
+ provider: row.provider,
+ sandbox: row.sandbox ?? false,
+ code: row.code,
+ name: row.name ?? undefined,
+ status: (row.status ?? 'inactive') as never,
+ active: row.active ?? undefined,
+ customerId: row.customer_provider_id ?? undefined,
+ discount: {
+ type: (row.discount_type ?? 'percentage') as
+ | 'percentage'
+ | 'fixed',
+ value: toNullableNumber(row.discount_value) ?? 0,
+ currency: row.discount_currency ?? undefined,
+ },
+ startsAt: toIsoString(row.starts_at) ?? undefined,
+ expiresAt: toIsoString(row.expires_at) ?? undefined,
+ minimumAmount: toNullableNumber(row.minimum_amount) ?? undefined,
+ minimumAmountCurrency: row.minimum_amount_currency ?? undefined,
+ redemptions: {
+ count: toNullableNumber(row.redemption_count) ?? 0,
+ max: toNullableNumber(row.max_redemptions),
+ },
+ metadata: row.metadata ?? undefined,
+ },
+ row.raw,
+ includeRaw,
+ ) as never;
+ });
+
+ return {
+ data,
+ total: Number(totalRow?.total ?? 0),
+ previous:
+ data.length === 0
+ ? null
+ : cursor?.mode === 'before'
+ ? hasExtra
+ ? encodeCustomerCursor(pageRows[0]!)
+ : null
+ : cursor
+ ? encodeCustomerCursor(pageRows[0]!)
+ : null,
+ next:
+ data.length === 0
+ ? null
+ : cursor?.mode === 'before'
+ ? encodeCustomerCursor(pageRows[pageRows.length - 1]!)
+ : hasExtra
+ ? encodeCustomerCursor(pageRows[pageRows.length - 1]!)
+ : null,
+ };
+ },
+ upsert: (schema, coupon) =>
+ upsertByProviderId(executor, schema, 'coupons', {
+ provider: coupon.provider,
+ provider_id: coupon.id,
+ version: getVersion(coupon, getInternalRaw(coupon)),
+ sandbox: coupon.sandbox,
+ code: coupon.code,
+ name: coupon.name ?? null,
+ status: coupon.status,
+ active: coupon.active ?? null,
+ customer_provider_id: coupon.customerId ?? null,
+ discount_type: coupon.discount.type,
+ discount_value: coupon.discount.value,
+ discount_currency:
+ coupon.discount.type === 'fixed'
+ ? (coupon.discount.currency ?? null)
+ : null,
+ starts_at: coupon.startsAt ?? null,
+ expires_at: coupon.expiresAt ?? null,
+ max_redemptions: coupon.redemptions.max,
+ redemption_count: coupon.redemptions.count,
+ minimum_amount: coupon.minimumAmount ?? null,
+ minimum_amount_currency: coupon.minimumAmountCurrency ?? null,
+ metadata: coupon.metadata ?? null,
+ data: withoutSchemaFields(
+ withoutRaw(coupon),
+ schema.tables.coupons.fields,
+ ),
+ raw: getPersistableRaw(executor, coupon),
+ deleted_at: null,
+ updated_at: new Date().toISOString(),
+ ...getExtraFieldValues(schema, 'coupons', coupon),
+ }),
+ markDeleted: (schema, coupon) =>
+ executor.execute({
+ sql: `UPDATE ${tableName(schema, 'coupons')}
+ SET version = $4, data = $5, raw = $6, deleted_at = NOW(), updated_at = NOW()
+ WHERE provider = $1 AND sandbox = $2 AND provider_id = $3`,
+ params: [
+ coupon.provider,
+ coupon.sandbox,
+ coupon.id,
+ getVersion(coupon, getInternalRaw(coupon)),
+ withoutRaw(coupon),
+ getPersistableRaw(executor, coupon),
+ ],
+ }),
+ },
pix: {
async findByProviderId(schema, provider, sandbox, id, options) {
const fields = Object.values(schema.tables.pix.fields);
From f0c33c1f042d4280e71dcee01e942cb43c8a6d2c Mon Sep 17 00:00:00 2001
From: almeida
Date: Sat, 13 Jun 2026 12:11:20 -0300
Subject: [PATCH 10/15] feat(web): document coupons in new providers
---
apps/web/content/docs/concepts/api.mdx | 15 +++++++++++++++
.../web/content/docs/guides/create-a-provider.mdx | 2 ++
2 files changed, 17 insertions(+)
diff --git a/apps/web/content/docs/concepts/api.mdx b/apps/web/content/docs/concepts/api.mdx
index cb38d36..de293df 100644
--- a/apps/web/content/docs/concepts/api.mdx
+++ b/apps/web/content/docs/concepts/api.mdx
@@ -53,6 +53,7 @@ It accepts:
The client managers exposed today are:
- `payments`
+- `coupons`
- `pix`
- `customers`
- `webhooks`
@@ -61,6 +62,20 @@ The client managers exposed today are:
Plugins may add more client surface through `extends()`.
+`payments.create()` now also accepts:
+
+- `couponCode?`
+- `allowCouponCodes?`
+
+Providers that advertise `coupons` expose:
+
+- `coupons.create()`
+- `coupons.get()`
+- `coupons.list()`
+- `coupons.update()`
+- `coupons.delete()`
+- `coupons.check()`
+
## Capability model
Providers declare capabilities instead of pretending every provider is equivalent.
diff --git a/apps/web/content/docs/guides/create-a-provider.mdx b/apps/web/content/docs/guides/create-a-provider.mdx
index b0dafc1..aafc381 100644
--- a/apps/web/content/docs/guides/create-a-provider.mdx
+++ b/apps/web/content/docs/guides/create-a-provider.mdx
@@ -170,6 +170,8 @@ Available input fields:
- `amount`
- `currency`
- `productIds?`
+- `couponCode?`
+- `allowCouponCodes?`
- `customer?`
- `description?`
- `metadata?`
From 73f44490db168c0c37592ba637ba58b805996390 Mon Sep 17 00:00:00 2001
From: almeida
Date: Sat, 13 Jun 2026 12:18:12 -0300
Subject: [PATCH 11/15] feat(web): add new page to document coupons
---
apps/web/.source/browser.ts | 2 +-
apps/web/.source/server.ts | 73 ++--
apps/web/content/docs/concepts/coupons.mdx | 440 +++++++++++++++++++++
apps/web/src/lib/docs-navigation.ts | 5 +
4 files changed, 483 insertions(+), 37 deletions(-)
create mode 100644 apps/web/content/docs/concepts/coupons.mdx
diff --git a/apps/web/.source/browser.ts b/apps/web/.source/browser.ts
index ecc2455..c472ec6 100644
--- a/apps/web/.source/browser.ts
+++ b/apps/web/.source/browser.ts
@@ -7,6 +7,6 @@ const create = browser();
const browserCollections = {
- docs: create.doc("docs", {"basic-usage.mdx": () => import("../content/docs/basic-usage.mdx?collection=docs"), "comparison.mdx": () => import("../content/docs/comparison.mdx?collection=docs"), "installation.mdx": () => import("../content/docs/installation.mdx?collection=docs"), "introduction.mdx": () => import("../content/docs/introduction.mdx?collection=docs"), "mcp.mdx": () => import("../content/docs/mcp.mdx?collection=docs"), "adapters/elysia.mdx": () => import("../content/docs/adapters/elysia.mdx?collection=docs"), "adapters/express.mdx": () => import("../content/docs/adapters/express.mdx?collection=docs"), "adapters/fastify.mdx": () => import("../content/docs/adapters/fastify.mdx?collection=docs"), "adapters/hono.mdx": () => import("../content/docs/adapters/hono.mdx?collection=docs"), "adapters/next.mdx": () => import("../content/docs/adapters/next.mdx?collection=docs"), "ai-resources/skills.mdx": () => import("../content/docs/ai-resources/skills.mdx?collection=docs"), "concepts/api.mdx": () => import("../content/docs/concepts/api.mdx?collection=docs"), "concepts/client.mdx": () => import("../content/docs/concepts/client.mdx?collection=docs"), "concepts/customers.mdx": () => import("../content/docs/concepts/customers.mdx?collection=docs"), "concepts/hooks.mdx": () => import("../content/docs/concepts/hooks.mdx?collection=docs"), "concepts/payment-providers.mdx": () => import("../content/docs/concepts/payment-providers.mdx?collection=docs"), "concepts/pix.mdx": () => import("../content/docs/concepts/pix.mdx?collection=docs"), "concepts/sandbox.mdx": () => import("../content/docs/concepts/sandbox.mdx?collection=docs"), "concepts/typescript.mdx": () => import("../content/docs/concepts/typescript.mdx?collection=docs"), "database/drizzle.mdx": () => import("../content/docs/database/drizzle.mdx?collection=docs"), "database/memory.mdx": () => import("../content/docs/database/memory.mdx?collection=docs"), "database/overview.mdx": () => import("../content/docs/database/overview.mdx?collection=docs"), "database/postgres.mdx": () => import("../content/docs/database/postgres.mdx?collection=docs"), "database/prisma.mdx": () => import("../content/docs/database/prisma.mdx?collection=docs"), "guides/create-a-database-adapter.mdx": () => import("../content/docs/guides/create-a-database-adapter.mdx?collection=docs"), "guides/create-a-provider.mdx": () => import("../content/docs/guides/create-a-provider.mdx?collection=docs"), "guides/create-your-first-plugin.mdx": () => import("../content/docs/guides/create-your-first-plugin.mdx?collection=docs"), "guides/database.mdx": () => import("../content/docs/guides/database.mdx?collection=docs"), "guides/optimize-performance.mdx": () => import("../content/docs/guides/optimize-performance.mdx?collection=docs"), "guides/plugins.mdx": () => import("../content/docs/guides/plugins.mdx?collection=docs"), "guides/provider-selection.mdx": () => import("../content/docs/guides/provider-selection.mdx?collection=docs"), "guides/testing.mdx": () => import("../content/docs/guides/testing.mdx?collection=docs"), "guides/webhooks-at-scale.mdx": () => import("../content/docs/guides/webhooks-at-scale.mdx?collection=docs"), "guides/webhooks.mdx": () => import("../content/docs/guides/webhooks.mdx?collection=docs"), "providers/abacatepay.mdx": () => import("../content/docs/providers/abacatepay.mdx?collection=docs"), "providers/dodo.mdx": () => import("../content/docs/providers/dodo.mdx?collection=docs"), "providers/polar.mdx": () => import("../content/docs/providers/polar.mdx?collection=docs"), "providers/stripe.mdx": () => import("../content/docs/providers/stripe.mdx?collection=docs"), "plugins/audit-logs.mdx": () => import("../content/docs/plugins/audit-logs.mdx?collection=docs"), "plugins/dash.mdx": () => import("../content/docs/plugins/dash.mdx?collection=docs"), "plugins/overview.mdx": () => import("../content/docs/plugins/overview.mdx?collection=docs"), "reference/cli.mdx": () => import("../content/docs/reference/cli.mdx?collection=docs"), "reference/client-options.mdx": () => import("../content/docs/reference/client-options.mdx?collection=docs"), "reference/database-schema.mdx": () => import("../content/docs/reference/database-schema.mdx?collection=docs"), "reference/errors.mdx": () => import("../content/docs/reference/errors.mdx?collection=docs"), "reference/plugin-api.mdx": () => import("../content/docs/reference/plugin-api.mdx?collection=docs"), }),
+ docs: create.doc("docs", {"basic-usage.mdx": () => import("../content/docs/basic-usage.mdx?collection=docs"), "comparison.mdx": () => import("../content/docs/comparison.mdx?collection=docs"), "installation.mdx": () => import("../content/docs/installation.mdx?collection=docs"), "introduction.mdx": () => import("../content/docs/introduction.mdx?collection=docs"), "mcp.mdx": () => import("../content/docs/mcp.mdx?collection=docs"), "adapters/elysia.mdx": () => import("../content/docs/adapters/elysia.mdx?collection=docs"), "adapters/express.mdx": () => import("../content/docs/adapters/express.mdx?collection=docs"), "adapters/fastify.mdx": () => import("../content/docs/adapters/fastify.mdx?collection=docs"), "adapters/hono.mdx": () => import("../content/docs/adapters/hono.mdx?collection=docs"), "adapters/next.mdx": () => import("../content/docs/adapters/next.mdx?collection=docs"), "ai-resources/skills.mdx": () => import("../content/docs/ai-resources/skills.mdx?collection=docs"), "database/drizzle.mdx": () => import("../content/docs/database/drizzle.mdx?collection=docs"), "database/memory.mdx": () => import("../content/docs/database/memory.mdx?collection=docs"), "database/overview.mdx": () => import("../content/docs/database/overview.mdx?collection=docs"), "database/postgres.mdx": () => import("../content/docs/database/postgres.mdx?collection=docs"), "database/prisma.mdx": () => import("../content/docs/database/prisma.mdx?collection=docs"), "concepts/api.mdx": () => import("../content/docs/concepts/api.mdx?collection=docs"), "concepts/client.mdx": () => import("../content/docs/concepts/client.mdx?collection=docs"), "concepts/coupons.mdx": () => import("../content/docs/concepts/coupons.mdx?collection=docs"), "concepts/customers.mdx": () => import("../content/docs/concepts/customers.mdx?collection=docs"), "concepts/hooks.mdx": () => import("../content/docs/concepts/hooks.mdx?collection=docs"), "concepts/payment-providers.mdx": () => import("../content/docs/concepts/payment-providers.mdx?collection=docs"), "concepts/pix.mdx": () => import("../content/docs/concepts/pix.mdx?collection=docs"), "concepts/sandbox.mdx": () => import("../content/docs/concepts/sandbox.mdx?collection=docs"), "concepts/typescript.mdx": () => import("../content/docs/concepts/typescript.mdx?collection=docs"), "guides/create-a-database-adapter.mdx": () => import("../content/docs/guides/create-a-database-adapter.mdx?collection=docs"), "guides/create-a-provider.mdx": () => import("../content/docs/guides/create-a-provider.mdx?collection=docs"), "guides/create-your-first-plugin.mdx": () => import("../content/docs/guides/create-your-first-plugin.mdx?collection=docs"), "guides/database.mdx": () => import("../content/docs/guides/database.mdx?collection=docs"), "guides/optimize-performance.mdx": () => import("../content/docs/guides/optimize-performance.mdx?collection=docs"), "guides/plugins.mdx": () => import("../content/docs/guides/plugins.mdx?collection=docs"), "guides/provider-selection.mdx": () => import("../content/docs/guides/provider-selection.mdx?collection=docs"), "guides/testing.mdx": () => import("../content/docs/guides/testing.mdx?collection=docs"), "guides/webhooks-at-scale.mdx": () => import("../content/docs/guides/webhooks-at-scale.mdx?collection=docs"), "guides/webhooks.mdx": () => import("../content/docs/guides/webhooks.mdx?collection=docs"), "providers/abacatepay.mdx": () => import("../content/docs/providers/abacatepay.mdx?collection=docs"), "providers/dodo.mdx": () => import("../content/docs/providers/dodo.mdx?collection=docs"), "providers/polar.mdx": () => import("../content/docs/providers/polar.mdx?collection=docs"), "providers/stripe.mdx": () => import("../content/docs/providers/stripe.mdx?collection=docs"), "plugins/audit-logs.mdx": () => import("../content/docs/plugins/audit-logs.mdx?collection=docs"), "plugins/dash.mdx": () => import("../content/docs/plugins/dash.mdx?collection=docs"), "plugins/overview.mdx": () => import("../content/docs/plugins/overview.mdx?collection=docs"), "reference/cli.mdx": () => import("../content/docs/reference/cli.mdx?collection=docs"), "reference/client-options.mdx": () => import("../content/docs/reference/client-options.mdx?collection=docs"), "reference/database-schema.mdx": () => import("../content/docs/reference/database-schema.mdx?collection=docs"), "reference/errors.mdx": () => import("../content/docs/reference/errors.mdx?collection=docs"), "reference/plugin-api.mdx": () => import("../content/docs/reference/plugin-api.mdx?collection=docs"), }),
};
export default browserCollections;
\ No newline at end of file
diff --git a/apps/web/.source/server.ts b/apps/web/.source/server.ts
index a5e160a..69700dc 100644
--- a/apps/web/.source/server.ts
+++ b/apps/web/.source/server.ts
@@ -1,39 +1,40 @@
// @ts-nocheck
-import { frontmatter as __fd_glob_45 } from "../content/docs/reference/plugin-api.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_44 } from "../content/docs/reference/errors.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_43 } from "../content/docs/reference/database-schema.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_42 } from "../content/docs/reference/client-options.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_41 } from "../content/docs/reference/cli.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_40 } from "../content/docs/plugins/overview.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_39 } from "../content/docs/plugins/dash.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_38 } from "../content/docs/plugins/audit-logs.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_37 } from "../content/docs/providers/stripe.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_36 } from "../content/docs/providers/polar.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_35 } from "../content/docs/providers/dodo.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_34 } from "../content/docs/providers/abacatepay.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_33 } from "../content/docs/guides/webhooks.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_32 } from "../content/docs/guides/webhooks-at-scale.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_31 } from "../content/docs/guides/testing.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_30 } from "../content/docs/guides/provider-selection.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_29 } from "../content/docs/guides/plugins.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_28 } from "../content/docs/guides/optimize-performance.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_27 } from "../content/docs/guides/database.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_26 } from "../content/docs/guides/create-your-first-plugin.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_25 } from "../content/docs/guides/create-a-provider.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_24 } from "../content/docs/guides/create-a-database-adapter.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_23 } from "../content/docs/database/prisma.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_22 } from "../content/docs/database/postgres.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_21 } from "../content/docs/database/overview.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_20 } from "../content/docs/database/memory.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_19 } from "../content/docs/database/drizzle.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_18 } from "../content/docs/concepts/typescript.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_17 } from "../content/docs/concepts/sandbox.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_16 } from "../content/docs/concepts/pix.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_15 } from "../content/docs/concepts/payment-providers.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_14 } from "../content/docs/concepts/hooks.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_13 } from "../content/docs/concepts/customers.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_12 } from "../content/docs/concepts/client.mdx?collection=docs&only=frontmatter"
-import { frontmatter as __fd_glob_11 } from "../content/docs/concepts/api.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_46 } from "../content/docs/reference/plugin-api.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_45 } from "../content/docs/reference/errors.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_44 } from "../content/docs/reference/database-schema.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_43 } from "../content/docs/reference/client-options.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_42 } from "../content/docs/reference/cli.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_41 } from "../content/docs/plugins/overview.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_40 } from "../content/docs/plugins/dash.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_39 } from "../content/docs/plugins/audit-logs.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_38 } from "../content/docs/providers/stripe.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_37 } from "../content/docs/providers/polar.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_36 } from "../content/docs/providers/dodo.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_35 } from "../content/docs/providers/abacatepay.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_34 } from "../content/docs/guides/webhooks.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_33 } from "../content/docs/guides/webhooks-at-scale.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_32 } from "../content/docs/guides/testing.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_31 } from "../content/docs/guides/provider-selection.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_30 } from "../content/docs/guides/plugins.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_29 } from "../content/docs/guides/optimize-performance.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_28 } from "../content/docs/guides/database.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_27 } from "../content/docs/guides/create-your-first-plugin.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_26 } from "../content/docs/guides/create-a-provider.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_25 } from "../content/docs/guides/create-a-database-adapter.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_24 } from "../content/docs/concepts/typescript.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_23 } from "../content/docs/concepts/sandbox.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_22 } from "../content/docs/concepts/pix.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_21 } from "../content/docs/concepts/payment-providers.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_20 } from "../content/docs/concepts/hooks.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_19 } from "../content/docs/concepts/customers.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_18 } from "../content/docs/concepts/coupons.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_17 } from "../content/docs/concepts/client.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_16 } from "../content/docs/concepts/api.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_15 } from "../content/docs/database/prisma.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_14 } from "../content/docs/database/postgres.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_13 } from "../content/docs/database/overview.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_12 } from "../content/docs/database/memory.mdx?collection=docs&only=frontmatter"
+import { frontmatter as __fd_glob_11 } from "../content/docs/database/drizzle.mdx?collection=docs&only=frontmatter"
import { frontmatter as __fd_glob_10 } from "../content/docs/ai-resources/skills.mdx?collection=docs&only=frontmatter"
import { frontmatter as __fd_glob_9 } from "../content/docs/adapters/next.mdx?collection=docs&only=frontmatter"
import { frontmatter as __fd_glob_8 } from "../content/docs/adapters/hono.mdx?collection=docs&only=frontmatter"
@@ -53,4 +54,4 @@ const create = server({"doc":{"passthroughs":["extractedReferences"]}});
-export const docs = await create.docsLazy("docs", "content/docs", {}, {"basic-usage.mdx": __fd_glob_0, "comparison.mdx": __fd_glob_1, "installation.mdx": __fd_glob_2, "introduction.mdx": __fd_glob_3, "mcp.mdx": __fd_glob_4, "adapters/elysia.mdx": __fd_glob_5, "adapters/express.mdx": __fd_glob_6, "adapters/fastify.mdx": __fd_glob_7, "adapters/hono.mdx": __fd_glob_8, "adapters/next.mdx": __fd_glob_9, "ai-resources/skills.mdx": __fd_glob_10, "concepts/api.mdx": __fd_glob_11, "concepts/client.mdx": __fd_glob_12, "concepts/customers.mdx": __fd_glob_13, "concepts/hooks.mdx": __fd_glob_14, "concepts/payment-providers.mdx": __fd_glob_15, "concepts/pix.mdx": __fd_glob_16, "concepts/sandbox.mdx": __fd_glob_17, "concepts/typescript.mdx": __fd_glob_18, "database/drizzle.mdx": __fd_glob_19, "database/memory.mdx": __fd_glob_20, "database/overview.mdx": __fd_glob_21, "database/postgres.mdx": __fd_glob_22, "database/prisma.mdx": __fd_glob_23, "guides/create-a-database-adapter.mdx": __fd_glob_24, "guides/create-a-provider.mdx": __fd_glob_25, "guides/create-your-first-plugin.mdx": __fd_glob_26, "guides/database.mdx": __fd_glob_27, "guides/optimize-performance.mdx": __fd_glob_28, "guides/plugins.mdx": __fd_glob_29, "guides/provider-selection.mdx": __fd_glob_30, "guides/testing.mdx": __fd_glob_31, "guides/webhooks-at-scale.mdx": __fd_glob_32, "guides/webhooks.mdx": __fd_glob_33, "providers/abacatepay.mdx": __fd_glob_34, "providers/dodo.mdx": __fd_glob_35, "providers/polar.mdx": __fd_glob_36, "providers/stripe.mdx": __fd_glob_37, "plugins/audit-logs.mdx": __fd_glob_38, "plugins/dash.mdx": __fd_glob_39, "plugins/overview.mdx": __fd_glob_40, "reference/cli.mdx": __fd_glob_41, "reference/client-options.mdx": __fd_glob_42, "reference/database-schema.mdx": __fd_glob_43, "reference/errors.mdx": __fd_glob_44, "reference/plugin-api.mdx": __fd_glob_45, }, {"basic-usage.mdx": () => import("../content/docs/basic-usage.mdx?collection=docs"), "comparison.mdx": () => import("../content/docs/comparison.mdx?collection=docs"), "installation.mdx": () => import("../content/docs/installation.mdx?collection=docs"), "introduction.mdx": () => import("../content/docs/introduction.mdx?collection=docs"), "mcp.mdx": () => import("../content/docs/mcp.mdx?collection=docs"), "adapters/elysia.mdx": () => import("../content/docs/adapters/elysia.mdx?collection=docs"), "adapters/express.mdx": () => import("../content/docs/adapters/express.mdx?collection=docs"), "adapters/fastify.mdx": () => import("../content/docs/adapters/fastify.mdx?collection=docs"), "adapters/hono.mdx": () => import("../content/docs/adapters/hono.mdx?collection=docs"), "adapters/next.mdx": () => import("../content/docs/adapters/next.mdx?collection=docs"), "ai-resources/skills.mdx": () => import("../content/docs/ai-resources/skills.mdx?collection=docs"), "concepts/api.mdx": () => import("../content/docs/concepts/api.mdx?collection=docs"), "concepts/client.mdx": () => import("../content/docs/concepts/client.mdx?collection=docs"), "concepts/customers.mdx": () => import("../content/docs/concepts/customers.mdx?collection=docs"), "concepts/hooks.mdx": () => import("../content/docs/concepts/hooks.mdx?collection=docs"), "concepts/payment-providers.mdx": () => import("../content/docs/concepts/payment-providers.mdx?collection=docs"), "concepts/pix.mdx": () => import("../content/docs/concepts/pix.mdx?collection=docs"), "concepts/sandbox.mdx": () => import("../content/docs/concepts/sandbox.mdx?collection=docs"), "concepts/typescript.mdx": () => import("../content/docs/concepts/typescript.mdx?collection=docs"), "database/drizzle.mdx": () => import("../content/docs/database/drizzle.mdx?collection=docs"), "database/memory.mdx": () => import("../content/docs/database/memory.mdx?collection=docs"), "database/overview.mdx": () => import("../content/docs/database/overview.mdx?collection=docs"), "database/postgres.mdx": () => import("../content/docs/database/postgres.mdx?collection=docs"), "database/prisma.mdx": () => import("../content/docs/database/prisma.mdx?collection=docs"), "guides/create-a-database-adapter.mdx": () => import("../content/docs/guides/create-a-database-adapter.mdx?collection=docs"), "guides/create-a-provider.mdx": () => import("../content/docs/guides/create-a-provider.mdx?collection=docs"), "guides/create-your-first-plugin.mdx": () => import("../content/docs/guides/create-your-first-plugin.mdx?collection=docs"), "guides/database.mdx": () => import("../content/docs/guides/database.mdx?collection=docs"), "guides/optimize-performance.mdx": () => import("../content/docs/guides/optimize-performance.mdx?collection=docs"), "guides/plugins.mdx": () => import("../content/docs/guides/plugins.mdx?collection=docs"), "guides/provider-selection.mdx": () => import("../content/docs/guides/provider-selection.mdx?collection=docs"), "guides/testing.mdx": () => import("../content/docs/guides/testing.mdx?collection=docs"), "guides/webhooks-at-scale.mdx": () => import("../content/docs/guides/webhooks-at-scale.mdx?collection=docs"), "guides/webhooks.mdx": () => import("../content/docs/guides/webhooks.mdx?collection=docs"), "providers/abacatepay.mdx": () => import("../content/docs/providers/abacatepay.mdx?collection=docs"), "providers/dodo.mdx": () => import("../content/docs/providers/dodo.mdx?collection=docs"), "providers/polar.mdx": () => import("../content/docs/providers/polar.mdx?collection=docs"), "providers/stripe.mdx": () => import("../content/docs/providers/stripe.mdx?collection=docs"), "plugins/audit-logs.mdx": () => import("../content/docs/plugins/audit-logs.mdx?collection=docs"), "plugins/dash.mdx": () => import("../content/docs/plugins/dash.mdx?collection=docs"), "plugins/overview.mdx": () => import("../content/docs/plugins/overview.mdx?collection=docs"), "reference/cli.mdx": () => import("../content/docs/reference/cli.mdx?collection=docs"), "reference/client-options.mdx": () => import("../content/docs/reference/client-options.mdx?collection=docs"), "reference/database-schema.mdx": () => import("../content/docs/reference/database-schema.mdx?collection=docs"), "reference/errors.mdx": () => import("../content/docs/reference/errors.mdx?collection=docs"), "reference/plugin-api.mdx": () => import("../content/docs/reference/plugin-api.mdx?collection=docs"), });
\ No newline at end of file
+export const docs = await create.docsLazy("docs", "content/docs", {}, {"basic-usage.mdx": __fd_glob_0, "comparison.mdx": __fd_glob_1, "installation.mdx": __fd_glob_2, "introduction.mdx": __fd_glob_3, "mcp.mdx": __fd_glob_4, "adapters/elysia.mdx": __fd_glob_5, "adapters/express.mdx": __fd_glob_6, "adapters/fastify.mdx": __fd_glob_7, "adapters/hono.mdx": __fd_glob_8, "adapters/next.mdx": __fd_glob_9, "ai-resources/skills.mdx": __fd_glob_10, "database/drizzle.mdx": __fd_glob_11, "database/memory.mdx": __fd_glob_12, "database/overview.mdx": __fd_glob_13, "database/postgres.mdx": __fd_glob_14, "database/prisma.mdx": __fd_glob_15, "concepts/api.mdx": __fd_glob_16, "concepts/client.mdx": __fd_glob_17, "concepts/coupons.mdx": __fd_glob_18, "concepts/customers.mdx": __fd_glob_19, "concepts/hooks.mdx": __fd_glob_20, "concepts/payment-providers.mdx": __fd_glob_21, "concepts/pix.mdx": __fd_glob_22, "concepts/sandbox.mdx": __fd_glob_23, "concepts/typescript.mdx": __fd_glob_24, "guides/create-a-database-adapter.mdx": __fd_glob_25, "guides/create-a-provider.mdx": __fd_glob_26, "guides/create-your-first-plugin.mdx": __fd_glob_27, "guides/database.mdx": __fd_glob_28, "guides/optimize-performance.mdx": __fd_glob_29, "guides/plugins.mdx": __fd_glob_30, "guides/provider-selection.mdx": __fd_glob_31, "guides/testing.mdx": __fd_glob_32, "guides/webhooks-at-scale.mdx": __fd_glob_33, "guides/webhooks.mdx": __fd_glob_34, "providers/abacatepay.mdx": __fd_glob_35, "providers/dodo.mdx": __fd_glob_36, "providers/polar.mdx": __fd_glob_37, "providers/stripe.mdx": __fd_glob_38, "plugins/audit-logs.mdx": __fd_glob_39, "plugins/dash.mdx": __fd_glob_40, "plugins/overview.mdx": __fd_glob_41, "reference/cli.mdx": __fd_glob_42, "reference/client-options.mdx": __fd_glob_43, "reference/database-schema.mdx": __fd_glob_44, "reference/errors.mdx": __fd_glob_45, "reference/plugin-api.mdx": __fd_glob_46, }, {"basic-usage.mdx": () => import("../content/docs/basic-usage.mdx?collection=docs"), "comparison.mdx": () => import("../content/docs/comparison.mdx?collection=docs"), "installation.mdx": () => import("../content/docs/installation.mdx?collection=docs"), "introduction.mdx": () => import("../content/docs/introduction.mdx?collection=docs"), "mcp.mdx": () => import("../content/docs/mcp.mdx?collection=docs"), "adapters/elysia.mdx": () => import("../content/docs/adapters/elysia.mdx?collection=docs"), "adapters/express.mdx": () => import("../content/docs/adapters/express.mdx?collection=docs"), "adapters/fastify.mdx": () => import("../content/docs/adapters/fastify.mdx?collection=docs"), "adapters/hono.mdx": () => import("../content/docs/adapters/hono.mdx?collection=docs"), "adapters/next.mdx": () => import("../content/docs/adapters/next.mdx?collection=docs"), "ai-resources/skills.mdx": () => import("../content/docs/ai-resources/skills.mdx?collection=docs"), "database/drizzle.mdx": () => import("../content/docs/database/drizzle.mdx?collection=docs"), "database/memory.mdx": () => import("../content/docs/database/memory.mdx?collection=docs"), "database/overview.mdx": () => import("../content/docs/database/overview.mdx?collection=docs"), "database/postgres.mdx": () => import("../content/docs/database/postgres.mdx?collection=docs"), "database/prisma.mdx": () => import("../content/docs/database/prisma.mdx?collection=docs"), "concepts/api.mdx": () => import("../content/docs/concepts/api.mdx?collection=docs"), "concepts/client.mdx": () => import("../content/docs/concepts/client.mdx?collection=docs"), "concepts/coupons.mdx": () => import("../content/docs/concepts/coupons.mdx?collection=docs"), "concepts/customers.mdx": () => import("../content/docs/concepts/customers.mdx?collection=docs"), "concepts/hooks.mdx": () => import("../content/docs/concepts/hooks.mdx?collection=docs"), "concepts/payment-providers.mdx": () => import("../content/docs/concepts/payment-providers.mdx?collection=docs"), "concepts/pix.mdx": () => import("../content/docs/concepts/pix.mdx?collection=docs"), "concepts/sandbox.mdx": () => import("../content/docs/concepts/sandbox.mdx?collection=docs"), "concepts/typescript.mdx": () => import("../content/docs/concepts/typescript.mdx?collection=docs"), "guides/create-a-database-adapter.mdx": () => import("../content/docs/guides/create-a-database-adapter.mdx?collection=docs"), "guides/create-a-provider.mdx": () => import("../content/docs/guides/create-a-provider.mdx?collection=docs"), "guides/create-your-first-plugin.mdx": () => import("../content/docs/guides/create-your-first-plugin.mdx?collection=docs"), "guides/database.mdx": () => import("../content/docs/guides/database.mdx?collection=docs"), "guides/optimize-performance.mdx": () => import("../content/docs/guides/optimize-performance.mdx?collection=docs"), "guides/plugins.mdx": () => import("../content/docs/guides/plugins.mdx?collection=docs"), "guides/provider-selection.mdx": () => import("../content/docs/guides/provider-selection.mdx?collection=docs"), "guides/testing.mdx": () => import("../content/docs/guides/testing.mdx?collection=docs"), "guides/webhooks-at-scale.mdx": () => import("../content/docs/guides/webhooks-at-scale.mdx?collection=docs"), "guides/webhooks.mdx": () => import("../content/docs/guides/webhooks.mdx?collection=docs"), "providers/abacatepay.mdx": () => import("../content/docs/providers/abacatepay.mdx?collection=docs"), "providers/dodo.mdx": () => import("../content/docs/providers/dodo.mdx?collection=docs"), "providers/polar.mdx": () => import("../content/docs/providers/polar.mdx?collection=docs"), "providers/stripe.mdx": () => import("../content/docs/providers/stripe.mdx?collection=docs"), "plugins/audit-logs.mdx": () => import("../content/docs/plugins/audit-logs.mdx?collection=docs"), "plugins/dash.mdx": () => import("../content/docs/plugins/dash.mdx?collection=docs"), "plugins/overview.mdx": () => import("../content/docs/plugins/overview.mdx?collection=docs"), "reference/cli.mdx": () => import("../content/docs/reference/cli.mdx?collection=docs"), "reference/client-options.mdx": () => import("../content/docs/reference/client-options.mdx?collection=docs"), "reference/database-schema.mdx": () => import("../content/docs/reference/database-schema.mdx?collection=docs"), "reference/errors.mdx": () => import("../content/docs/reference/errors.mdx?collection=docs"), "reference/plugin-api.mdx": () => import("../content/docs/reference/plugin-api.mdx?collection=docs"), });
\ No newline at end of file
diff --git a/apps/web/content/docs/concepts/coupons.mdx b/apps/web/content/docs/concepts/coupons.mdx
new file mode 100644
index 0000000..cc0ecae
--- /dev/null
+++ b/apps/web/content/docs/concepts/coupons.mdx
@@ -0,0 +1,440 @@
+---
+title: Coupons
+description: Learn the full Paymesh coupons surface, including CRUD, checkout integration, validation previews, database persistence, provider support, and current provider-specific limits.
+---
+
+## Overview
+
+Paymesh exposes coupons as a first-party client surface:
+
+- `paymesh.coupons.create()`
+- `paymesh.coupons.get()`
+- `paymesh.coupons.list()`
+- `paymesh.coupons.update()`
+- `paymesh.coupons.delete()`
+- `paymesh.coupons.check()`
+
+This is the normalized layer for provider-native coupon systems. In the current repository, coupon support is implemented for:
+
+- `@paymesh/stripe`
+- `@paymesh/polar`
+
+Providers that do not advertise `capabilities.coupons === true` do not expose this surface.
+
+## Capability matrix
+
+| Provider | `coupons` | Notes |
+| --- | --- | --- |
+| Stripe | yes | Backed by Stripe coupons + promotion codes. |
+| Polar | yes | Backed by Polar discounts. |
+| AbacatePay | no | No coupon surface in the current provider package. |
+| Dodo | no | No coupon surface in the current provider package. |
+
+## Quick start
+
+```ts title="src/lib/paymesh.ts"
+import { createClient } from "paymesh";
+import { postgres } from "@paymesh/postgres";
+import { stripe } from "@paymesh/stripe";
+
+export const paymesh = createClient({
+ provider: stripe({
+ secret: "sk_test_123",
+ webhookSecret: "whsec_123",
+ }),
+ database: postgres("postgres://postgres:postgres@localhost:5432/paymesh"),
+});
+```
+
+```ts title="src/server/coupons.ts"
+const coupon = await paymesh.coupons.create({
+ code: "WELCOME10",
+ name: "Welcome 10%",
+ discount: {
+ type: "percentage",
+ value: 10,
+ },
+ duration: {
+ type: "once",
+ },
+});
+```
+
+## Coupon shape
+
+The normalized coupon returned by Paymesh contains:
+
+```ts title="shape.ts"
+type Coupon = {
+ id: string;
+ provider: string;
+ sandbox: boolean;
+ code: string;
+ name?: string;
+ status: "active" | "inactive" | "scheduled" | "expired" | "deleted";
+ active?: boolean;
+ discount:
+ | {
+ type: "percentage";
+ value: number;
+ }
+ | {
+ type: "fixed";
+ value: number;
+ currency?: string;
+ amounts?: Record;
+ };
+ duration?: {
+ type: "once" | "forever" | "repeating";
+ durationInMonths?: number;
+ };
+ startsAt?: string;
+ expiresAt?: string;
+ customerId?: string;
+ firstTimeOnly?: boolean;
+ minimumAmount?: number;
+ minimumAmountCurrency?: string;
+ appliesTo?: {
+ products?: string[];
+ prices?: string[];
+ };
+ redemptions: {
+ count: number;
+ max: number | null;
+ };
+ metadata?: Record;
+};
+```
+
+## Creating coupons
+
+### Percentage discount
+
+```ts title="percentage.ts"
+const coupon = await paymesh.coupons.create({
+ code: "WELCOME10",
+ name: "Welcome 10%",
+ discount: {
+ type: "percentage",
+ value: 10,
+ },
+ duration: {
+ type: "once",
+ },
+});
+```
+
+### Fixed discount
+
+```ts title="fixed.ts"
+const coupon = await paymesh.coupons.create({
+ code: "SAVE20",
+ name: "Save $20",
+ discount: {
+ type: "fixed",
+ value: 2000,
+ currency: "USD",
+ },
+ duration: {
+ type: "once",
+ },
+});
+```
+
+### Product-scoped coupon
+
+```ts title="scoped.ts"
+const coupon = await paymesh.coupons.create({
+ code: "PRO20",
+ discount: {
+ type: "percentage",
+ value: 20,
+ },
+ appliesTo: {
+ products: ["prod_pro"],
+ },
+});
+```
+
+### Expiration and redemption limits
+
+```ts title="limits.ts"
+const coupon = await paymesh.coupons.create({
+ code: "LAUNCH50",
+ discount: {
+ type: "percentage",
+ value: 50,
+ },
+ expiresAt: "2026-12-31T23:59:59.000Z",
+ maxRedemptions: 100,
+});
+```
+
+## Reading and listing coupons
+
+```ts title="get.ts"
+const coupon = await paymesh.coupons.get("promo_abc");
+```
+
+```ts title="list.ts"
+const page = await paymesh.coupons.list({
+ limit: 20,
+ code: "WELCOME10",
+});
+```
+
+`list()` is paginated and returns:
+
+```ts title="list-result.ts"
+{
+ data: Coupon[];
+ total: number;
+ previous: string | null;
+ next: string | null;
+}
+```
+
+## Updating coupons
+
+```ts title="update.ts"
+const coupon = await paymesh.coupons.update("promo_abc", {
+ active: false,
+ name: "Paused campaign",
+});
+```
+
+Update support is provider-dependent. Paymesh only forwards updates that the active provider supports with equivalent semantics.
+
+If the update request includes fields the provider cannot honor safely, Paymesh throws `invalid_request` instead of silently weakening the rule set.
+
+## Deleting coupons
+
+```ts title="delete.ts"
+const result = await paymesh.coupons.delete("promo_abc");
+```
+
+The normalized delete result is:
+
+```ts title="delete-result.ts"
+{
+ id: "promo_abc",
+ provider: "stripe",
+ sandbox: true,
+ deleted: true,
+ code: "WELCOME10",
+}
+```
+
+## Checkout integration
+
+Coupons are integrated directly into `payments.create()`.
+
+### Pre-apply a coupon code
+
+```ts title="checkout-coupon.ts"
+const checkout = await paymesh.payments.create({
+ amount: 4900,
+ currency: "USD",
+ productIds: ["prod_pro"],
+ couponCode: "PRO20",
+});
+```
+
+### Allow manual coupon entry in hosted checkout
+
+```ts title="checkout-allow.ts"
+const checkout = await paymesh.payments.create({
+ amount: 4900,
+ currency: "USD",
+ productIds: ["prod_pro"],
+ allowCouponCodes: true,
+});
+```
+
+### Use both
+
+```ts title="checkout-both.ts"
+const checkout = await paymesh.payments.create({
+ amount: 4900,
+ currency: "USD",
+ productIds: ["prod_pro"],
+ couponCode: "PRO20",
+ allowCouponCodes: true,
+});
+```
+
+Current provider behavior:
+
+- Stripe enables `allow_promotion_codes` and pre-applies a matching promotion code.
+- Polar enables `allow_discount_codes` and resolves `couponCode` into a `discount_id`.
+
+## Coupon validation with preview
+
+`coupons.check()` validates a code against the current cart or amount context and returns a normalized preview when Paymesh can calculate one safely.
+
+```ts title="check.ts"
+const result = await paymesh.coupons.check({
+ code: "WELCOME10",
+ amount: 10000,
+ currency: "USD",
+ productIds: ["prod_pro"],
+});
+```
+
+Successful result:
+
+```ts title="check-valid.ts"
+{
+ valid: true,
+ coupon: {
+ id: "promo_abc",
+ code: "WELCOME10",
+ discount: {
+ type: "percentage",
+ value: 10,
+ },
+ },
+ preview: {
+ subtotal: 10000,
+ discountTotal: 1000,
+ total: 9000,
+ currency: "USD",
+ },
+}
+```
+
+Failed result:
+
+```ts title="check-invalid.ts"
+{
+ valid: false,
+ reason: "minimum_amount_not_reached",
+ coupon: {
+ id: "promo_abc",
+ code: "WELCOME10",
+ },
+}
+```
+
+### Invalid reasons
+
+`coupons.check()` may return:
+
+- `not_found`
+- `inactive`
+- `expired`
+- `not_started`
+- `max_redemptions_reached`
+- `customer_not_eligible`
+- `product_not_eligible`
+- `currency_not_supported`
+- `minimum_amount_not_reached`
+- `provider_error`
+
+### Preview sources
+
+Paymesh currently computes previews using the provider surface that exists today:
+
+- explicit `amount` + `currency`
+- provider price lookups when the provider makes that practical
+- product-based cart resolution when the provider catalog can be queried safely
+
+If the provider cannot validate a requested restriction with strong guarantees, the result is `valid: false` with `reason: "provider_error"` instead of a guessed approval.
+
+## Database persistence
+
+If a Paymesh database adapter is configured, coupons are persisted automatically.
+
+That persistence is useful for:
+
+- operational visibility
+- local list queries
+- stable coupon lookup by provider id
+- keeping a normalized coupon record beside provider raw payloads
+
+The built-in schema now includes a `coupons` table. Paymesh stores normalized columns such as:
+
+- `provider`
+- `provider_id`
+- `code`
+- `status`
+- `active`
+- `discount_type`
+- `discount_value`
+- `discount_currency`
+- `starts_at`
+- `expires_at`
+- `max_redemptions`
+- `redemption_count`
+- `minimum_amount`
+- `minimum_amount_currency`
+- `metadata`
+- `data`
+- `raw`
+
+## Provider-specific behavior
+
+### Stripe
+
+Stripe coupon support is modeled around:
+
+- Stripe `coupon`
+- Stripe `promotion_code`
+
+Important details:
+
+- the Paymesh `Coupon.id` maps to the Stripe promotion code id
+- `couponCode` in checkout resolves a Stripe promotion code by `code`
+- `allowCouponCodes` maps to Stripe Checkout `allow_promotion_codes`
+- `startsAt` is not supported with equivalent semantics and is rejected
+- `appliesTo.prices` is not supported and is rejected
+- Stripe updates are intentionally narrow in the current implementation; only safe fields such as `active` and `metadata` are updated
+- first-time-only restrictions may cause `check()` to return `provider_error` when Stripe cannot be validated locally with strong guarantees
+
+See [Stripe](/home/almeida/Documents/Projects/paymesh/paymesh/apps/web/content/docs/providers/stripe.mdx).
+
+### Polar
+
+Polar coupon support is modeled around Polar discounts.
+
+Important details:
+
+- `couponCode` in checkout resolves a Polar discount and passes its `discount_id`
+- `allowCouponCodes` maps to Polar checkout manual discount entry
+- `appliesTo.products` is supported
+- `appliesTo.prices` is not supported and is rejected
+- some Polar accounts may require `organizationId` in provider config for discount APIs
+
+See [Polar](/home/almeida/Documents/Projects/paymesh/paymesh/apps/web/content/docs/providers/polar.mdx).
+
+## Error behavior
+
+Paymesh treats unsupported coupon fields as hard errors.
+
+That means this fails intentionally:
+
+```ts title="unsupported.ts"
+await paymesh.coupons.create({
+ code: "PRICE20",
+ discount: {
+ type: "percentage",
+ value: 20,
+ },
+ appliesTo: {
+ prices: ["price_monthly_pro"],
+ },
+});
+```
+
+The goal is to avoid creating a provider-side coupon that is looser than the caller requested.
+
+## Recommended usage
+
+Use coupons when:
+
+- the provider already has native discount infrastructure you want to preserve
+- the checkout should remain provider-hosted
+- you want normalized CRUD and validation flows across providers
+- you want coupon data persisted locally alongside the rest of the billing state
+
+Avoid assuming every provider supports identical coupon semantics. Paymesh normalizes the common surface, but provider-native rules still matter at the edges.
diff --git a/apps/web/src/lib/docs-navigation.ts b/apps/web/src/lib/docs-navigation.ts
index f793647..83897da 100644
--- a/apps/web/src/lib/docs-navigation.ts
+++ b/apps/web/src/lib/docs-navigation.ts
@@ -50,6 +50,11 @@ export const docsNavigation: DocNavGroup[] = [
href: '/docs/concepts/client',
icon: 'terminal-square',
},
+ {
+ label: 'Coupons',
+ href: '/docs/concepts/coupons',
+ icon: 'ticket-percent',
+ },
{ label: 'Sandbox', href: '/docs/concepts/sandbox', icon: 'flask' },
{ label: 'Hooks', href: '/docs/concepts/hooks', icon: 'hook' },
{
From a1708eeabfe1b31197488add969f65443d7b7c12 Mon Sep 17 00:00:00 2001
From: almeida
Date: Sat, 13 Jun 2026 12:24:31 -0300
Subject: [PATCH 12/15] feat(coupons): add support to activate, deactivate and
archive funcs
---
apps/web/content/docs/concepts/api.mdx | 3 +
apps/web/content/docs/concepts/coupons.mdx | 26 +++++
packages/paymesh/src/client/coupons.ts | 26 ++++-
packages/paymesh/src/types/client.ts | 15 +++
packages/paymesh/test/client.test.ts | 119 ++++++++++++++++-----
5 files changed, 163 insertions(+), 26 deletions(-)
diff --git a/apps/web/content/docs/concepts/api.mdx b/apps/web/content/docs/concepts/api.mdx
index de293df..62080fb 100644
--- a/apps/web/content/docs/concepts/api.mdx
+++ b/apps/web/content/docs/concepts/api.mdx
@@ -73,7 +73,10 @@ Providers that advertise `coupons` expose:
- `coupons.get()`
- `coupons.list()`
- `coupons.update()`
+- `coupons.activate()`
+- `coupons.deactivate()`
- `coupons.delete()`
+- `coupons.archive()`
- `coupons.check()`
## Capability model
diff --git a/apps/web/content/docs/concepts/coupons.mdx b/apps/web/content/docs/concepts/coupons.mdx
index cc0ecae..8cea1c2 100644
--- a/apps/web/content/docs/concepts/coupons.mdx
+++ b/apps/web/content/docs/concepts/coupons.mdx
@@ -11,7 +11,10 @@ Paymesh exposes coupons as a first-party client surface:
- `paymesh.coupons.get()`
- `paymesh.coupons.list()`
- `paymesh.coupons.update()`
+- `paymesh.coupons.activate()`
+- `paymesh.coupons.deactivate()`
- `paymesh.coupons.delete()`
+- `paymesh.coupons.archive()`
- `paymesh.coupons.check()`
This is the normalized layer for provider-native coupon systems. In the current repository, coupon support is implemented for:
@@ -207,6 +210,29 @@ Update support is provider-dependent. Paymesh only forwards updates that the act
If the update request includes fields the provider cannot honor safely, Paymesh throws `invalid_request` instead of silently weakening the rule set.
+## Lifecycle helpers
+
+Paymesh also exposes convenience helpers for common coupon state transitions:
+
+```ts title="helpers.ts"
+await paymesh.coupons.activate("promo_abc");
+await paymesh.coupons.deactivate("promo_abc");
+await paymesh.coupons.archive("promo_abc");
+```
+
+Current helper semantics:
+
+- `activate(id)` delegates to `coupons.update(id, { active: true })`
+- `deactivate(id)` delegates to `coupons.update(id, { active: false })`
+- `archive(id)` delegates to `coupons.delete(id)`
+
+That means:
+
+- `activate()` and `deactivate()` return a normalized `Coupon`
+- `archive()` returns the same normalized delete result as `coupons.delete()`
+
+Use these helpers when your app wants explicit intent without hand-writing tiny update payloads.
+
## Deleting coupons
```ts title="delete.ts"
diff --git a/packages/paymesh/src/client/coupons.ts b/packages/paymesh/src/client/coupons.ts
index 84a6c9b..50bfcfa 100644
--- a/packages/paymesh/src/client/coupons.ts
+++ b/packages/paymesh/src/client/coupons.ts
@@ -44,7 +44,7 @@ export function createCouponsClient<
const getProviderCoupons = () =>
getRequiredProviderFeature(provider.coupons, provider.id, 'coupons');
- return {
+ const client = {
create: async (
data: PaymeshCouponCreateData,
requestOptions?: ProviderRequestOptions,
@@ -174,6 +174,24 @@ export function createCouponsClient<
return resolvedCoupon;
},
+ activate: async (
+ id: string,
+ requestOptions?: ProviderRequestOptions,
+ ) =>
+ client.update(
+ id,
+ { active: true } as PaymeshCouponUpdateData,
+ requestOptions,
+ ),
+ deactivate: async (
+ id: string,
+ requestOptions?: ProviderRequestOptions,
+ ) =>
+ client.update(
+ id,
+ { active: false } as PaymeshCouponUpdateData,
+ requestOptions,
+ ),
delete: async (
id: string,
requestOptions?: ProviderRequestOptions,
@@ -191,6 +209,10 @@ export function createCouponsClient<
return result;
},
+ archive: async (
+ id: string,
+ requestOptions?: ProviderRequestOptions,
+ ) => client.delete(id, requestOptions),
check: async (
data: CouponCheckData,
requestOptions?: ProviderRequestOptions,
@@ -212,4 +234,6 @@ export function createCouponsClient<
return result as PaymeshCouponCheckResult;
},
};
+
+ return client;
}
diff --git a/packages/paymesh/src/types/client.ts b/packages/paymesh/src/types/client.ts
index 53e9398..951c251 100644
--- a/packages/paymesh/src/types/client.ts
+++ b/packages/paymesh/src/types/client.ts
@@ -405,11 +405,26 @@ export interface PaymeshCouponsClient<
data: PaymeshCouponUpdateData,
options?: ProviderRequestOptions,
): Promise>;
+ /** Marks a coupon as active through the configured provider. */
+ activate(
+ id: string,
+ options?: ProviderRequestOptions,
+ ): Promise>;
+ /** Marks a coupon as inactive through the configured provider. */
+ deactivate(
+ id: string,
+ options?: ProviderRequestOptions,
+ ): Promise>;
/** Deletes a coupon through the configured provider. */
delete(
id: string,
options?: ProviderRequestOptions,
): Promise>;
+ /** Archives a coupon through the configured provider. */
+ archive(
+ id: string,
+ options?: ProviderRequestOptions,
+ ): Promise>;
/** Validates a coupon code against the provided cart context. */
check(
data: CouponCheckData,
diff --git a/packages/paymesh/test/client.test.ts b/packages/paymesh/test/client.test.ts
index 1e26276..85548fc 100644
--- a/packages/paymesh/test/client.test.ts
+++ b/packages/paymesh/test/client.test.ts
@@ -4,6 +4,7 @@ import {
type CouponCheckData,
type CouponCheckResult,
type CouponCreateData,
+ type CouponDeleteResult,
type Customer,
createClient,
defineDatabaseAdapter,
@@ -189,6 +190,59 @@ describe('client', () => {
});
});
+ test('provides coupon lifecycle helpers', async () => {
+ const updates: Array = [];
+ const deletes: string[] = [];
+ const client = createClient({
+ provider: createStubProvider({
+ onCouponUpdate: async (_id, data, options) => {
+ updates.push(data);
+ return withRaw(
+ {
+ id: 'promo_123',
+ provider: 'stub',
+ sandbox: false,
+ code: 'WELCOME10',
+ status: (data.active ? 'active' : 'inactive') as
+ | 'active'
+ | 'inactive',
+ active:
+ typeof data.active === 'boolean' ? data.active : undefined,
+ discount: { type: 'percentage', value: 10 },
+ redemptions: { count: 0, max: null },
+ },
+ { id: 'raw_coupon_123' },
+ options?.includeRaw,
+ );
+ },
+ onCouponDelete: async (id, options) => {
+ deletes.push(id);
+ return withRaw(
+ {
+ id,
+ provider: 'stub',
+ sandbox: false,
+ deleted: true,
+ code: 'WELCOME10',
+ },
+ { id: 'raw_coupon_123' },
+ options?.includeRaw,
+ );
+ },
+ }),
+ });
+
+ const activated = await client.coupons.activate('promo_123');
+ const deactivated = await client.coupons.deactivate('promo_123');
+ const archived = await client.coupons.archive('promo_123');
+
+ expect(updates).toEqual([{ active: true }, { active: false }]);
+ expect(deletes).toEqual(['promo_123']);
+ expect(activated.active).toBe(true);
+ expect(deactivated.active).toBe(false);
+ expect(archived.deleted).toBe(true);
+ });
+
test('delegates client.isSandbox() to the provider', () => {
const client = createClient({
provider: createStubProvider({ sandbox: true }),
@@ -1546,6 +1600,8 @@ function createStubProvider({
onCouponCreate,
onCouponGet,
onCouponCheck,
+ onCouponUpdate,
+ onCouponDelete,
}: {
sandbox?: boolean;
onPaymentCreate?: (
@@ -1585,6 +1641,15 @@ function createStubProvider({
data: CouponCheckData,
options?: ProviderRequestOptions,
) => Promise>;
+ onCouponUpdate?: (
+ id: string,
+ data: Record,
+ options?: ProviderRequestOptions,
+ ) => Promise>;
+ onCouponDelete?: (
+ id: string,
+ options?: ProviderRequestOptions,
+ ) => Promise>;
} = {}) {
return defineProvider({
id: 'stub',
@@ -1668,31 +1733,35 @@ function createStubProvider({
next: null,
}),
update: async (_id, _data, options) =>
- withRaw(
- {
- id: 'promo_123',
- provider: 'stub',
- sandbox,
- code: 'WELCOME10',
- status: 'active' as const,
- discount: { type: 'percentage' as const, value: 10 },
- redemptions: { count: 0, max: null },
- },
- { id: 'raw_coupon_123' },
- options?.includeRaw,
- ),
- delete: async (_id, options) =>
- withRaw(
- {
- id: 'promo_123',
- provider: 'stub',
- sandbox,
- deleted: true,
- code: 'WELCOME10',
- },
- { id: 'raw_coupon_123' },
- options?.includeRaw,
- ),
+ onCouponUpdate
+ ? onCouponUpdate(_id, _data as Record, options)
+ : withRaw(
+ {
+ id: 'promo_123',
+ provider: 'stub',
+ sandbox,
+ code: 'WELCOME10',
+ status: 'active' as const,
+ discount: { type: 'percentage' as const, value: 10 },
+ redemptions: { count: 0, max: null },
+ },
+ { id: 'raw_coupon_123' },
+ options?.includeRaw,
+ ),
+ delete: onCouponDelete
+ ? onCouponDelete
+ : async (_id, options) =>
+ withRaw(
+ {
+ id: 'promo_123',
+ provider: 'stub',
+ sandbox,
+ deleted: true,
+ code: 'WELCOME10',
+ },
+ { id: 'raw_coupon_123' },
+ options?.includeRaw,
+ ),
check: onCouponCheck
? onCouponCheck
: async (_data, options) => ({
From 42799e147f750aa05beebca65a2fc3cf381dfcb0 Mon Sep 17 00:00:00 2001
From: almeida
Date: Mon, 15 Jun 2026 09:05:01 -0300
Subject: [PATCH 13/15] feat(coupons): add support to hooks events
---
apps/web/content/docs/concepts/api.mdx | 12 +
apps/web/content/docs/concepts/coupons.mdx | 156 +++++++++-
packages/abacatepay/src/shared/constants.ts | 2 +-
packages/cli/README.md | 2 +-
packages/cli/src/commands/trigger.ts | 5 +-
packages/cli/test/cli.test.ts | 20 +-
packages/dodo/src/shared/constants.ts | 2 +-
packages/paymesh/src/client/coupons.ts | 68 +++--
packages/paymesh/src/database/webhooks.ts | 136 ++++++++-
packages/paymesh/src/plugins/runtime.ts | 7 +
packages/paymesh/src/types/client.ts | 42 ++-
packages/paymesh/src/types/providers.ts | 13 +
packages/paymesh/test/client.test.ts | 102 ++++++-
packages/paymesh/test/database.test.ts | 314 +++++++++++++++++++-
14 files changed, 828 insertions(+), 53 deletions(-)
diff --git a/apps/web/content/docs/concepts/api.mdx b/apps/web/content/docs/concepts/api.mdx
index 62080fb..ab8775d 100644
--- a/apps/web/content/docs/concepts/api.mdx
+++ b/apps/web/content/docs/concepts/api.mdx
@@ -79,6 +79,11 @@ Providers that advertise `coupons` expose:
- `coupons.archive()`
- `coupons.check()`
+Coupon lifecycle helpers now have distinct semantics:
+
+- `archive()` keeps the coupon mapped locally and marks it unusable
+- `delete()` removes the coupon from the provider when supported and marks the local mapping as deleted
+
## Capability model
Providers declare capabilities instead of pretending every provider is equivalent.
@@ -105,6 +110,13 @@ Webhook delivery is normalized into Paymesh event types such as:
- `payment.failed`
- `payment.canceled`
- `payment.refunded`
+- `coupon.created`
+- `coupon.updated`
+- `coupon.deleted`
+- `coupon.archived`
+- `coupon.redeemed`
+- `coupon.redemption_failed`
+- `coupon.expired`
- `customer.created`
- `customer.updated`
- `customer.deleted`
diff --git a/apps/web/content/docs/concepts/coupons.mdx b/apps/web/content/docs/concepts/coupons.mdx
index 8cea1c2..62c21f9 100644
--- a/apps/web/content/docs/concepts/coupons.mdx
+++ b/apps/web/content/docs/concepts/coupons.mdx
@@ -74,7 +74,13 @@ type Coupon = {
sandbox: boolean;
code: string;
name?: string;
- status: "active" | "inactive" | "scheduled" | "expired" | "deleted";
+ status:
+ | "active"
+ | "inactive"
+ | "archived"
+ | "scheduled"
+ | "expired"
+ | "deleted";
active?: boolean;
discount:
| {
@@ -224,15 +230,30 @@ Current helper semantics:
- `activate(id)` delegates to `coupons.update(id, { active: true })`
- `deactivate(id)` delegates to `coupons.update(id, { active: false })`
-- `archive(id)` delegates to `coupons.delete(id)`
+- `archive(id)` archives the coupon and returns a normalized `Coupon`
That means:
-- `activate()` and `deactivate()` return a normalized `Coupon`
-- `archive()` returns the same normalized delete result as `coupons.delete()`
+- `activate()`, `deactivate()`, and `archive()` return a normalized `Coupon`
+- `archive()` is intentionally different from `delete()`
+- `archive()` keeps the coupon mapped in Paymesh, but marks it unusable
+- `delete()` removes the provider-side coupon object when the provider supports hard deletion and marks the local mapping as deleted
Use these helpers when your app wants explicit intent without hand-writing tiny update payloads.
+### `archive()` vs `delete()`
+
+Use `archive()` when you want to stop future use of a coupon but keep a stable local record and history for it.
+
+Use `delete()` when you want Paymesh to remove the coupon from the provider and mark the local mapping as deleted.
+
+Current provider notes:
+
+- Stripe archives by deactivating the promotion code and normalizing the result as `status: "archived"`
+- Stripe deletes by deleting the underlying Stripe coupon and marking the Paymesh mapping as deleted
+- Polar archives by setting the discount inactive and normalizing the result as `status: "archived"`
+- Polar deletes through the Polar discount delete endpoint
+
## Deleting coupons
```ts title="delete.ts"
@@ -398,6 +419,133 @@ The built-in schema now includes a `coupons` table. Paymesh stores normalized co
- `data`
- `raw`
+Archived coupons stay in the local `coupons` table with `status: "archived"`. Deleted coupons are marked as deleted in the local mapping.
+
+## Coupon hooks
+
+When a provider webhook is normalized through `paymesh.webhooks.handle()`, Paymesh can dispatch coupon lifecycle hooks:
+
+- `onCouponCreated`
+- `onCouponUpdated`
+- `onCouponDeleted`
+- `onCouponArchived`
+- `onCouponRedeemed`
+- `onCouponRedemptionFailed`
+- `onCouponExpired`
+
+Example:
+
+```ts title="hooks.ts"
+import { createClient } from "paymesh";
+import { stripe } from "@paymesh/stripe";
+
+export const paymesh = createClient({
+ provider: stripe({
+ secret: "sk_test_123",
+ webhookSecret: "whsec_123",
+ }),
+ hooks: {
+ onCouponRedeemed: async (event) => {
+ console.log("coupon redeemed", {
+ id: event.data.id,
+ code: event.data.code,
+ count: event.data.redemptions.count,
+ });
+ },
+ onCouponArchived: async (event) => {
+ console.log("coupon archived", event.data.code);
+ },
+ },
+});
+```
+
+### Normalized coupon event names
+
+The normalized event types are:
+
+- `coupon.created`
+- `coupon.updated`
+- `coupon.deleted`
+- `coupon.archived`
+- `coupon.redeemed`
+- `coupon.redemption_failed`
+- `coupon.expired`
+
+### Event payloads
+
+These hooks receive the same normalized event envelope as other Paymesh webhook hooks:
+
+```ts title="coupon-event.ts"
+{
+ id: "evt_123",
+ type: "coupon.redeemed",
+ provider: "stripe",
+ sandbox: true,
+ data: {
+ id: "promo_abc",
+ code: "WELCOME10",
+ status: "active",
+ redemptions: {
+ count: 1,
+ max: null,
+ },
+ },
+ context: {
+ deliveryId: "evt_123",
+ dispatchedAt: "2026-06-13T12:00:00.000Z",
+ request: Request,
+ hook: "onCouponRedeemed",
+ },
+}
+```
+
+`coupon.deleted` is the exception. It uses the normalized coupon delete payload:
+
+```ts title="coupon-deleted-event.ts"
+{
+ id: "evt_456",
+ type: "coupon.deleted",
+ data: {
+ id: "promo_abc",
+ provider: "stripe",
+ sandbox: true,
+ deleted: true,
+ code: "WELCOME10",
+ },
+}
+```
+
+### How Paymesh decides between `updated`, `archived`, `redeemed`, and `expired`
+
+Some providers only emit a generic coupon update webhook.
+
+When Paymesh has a database configured, it compares the incoming coupon state with the previously persisted coupon and upgrades the normalized event when possible:
+
+- if the coupon becomes unusable by archive semantics, Paymesh emits `coupon.archived`
+- if the coupon transitions into expiration, Paymesh emits `coupon.expired`
+- if the redemption counter increases, Paymesh emits `coupon.redeemed`
+- otherwise, Paymesh emits `coupon.updated`
+
+Without a configured database, Paymesh cannot compare previous coupon state, so those provider updates stay as `coupon.updated`.
+
+### Provider webhook support
+
+Current support in this repository:
+
+| Provider | Coupon webhook mapping |
+| --- | --- |
+| Stripe | Supports coupon creation and update webhook normalization from Stripe promotion code events. With a configured Paymesh database, generic Stripe updates can be upgraded to `coupon.archived`, `coupon.redeemed`, or `coupon.expired` based on previous stored state. |
+| Polar | No native coupon webhook mapping in the current provider package. |
+
+### Notes on `coupon.redemption_failed`
+
+The hook and normalized event type exist in the public API, but they only fire when a provider exposes a failure webhook that can be mapped safely.
+
+In the current repository:
+
+- Stripe does not currently expose a mapped native coupon redemption failure event through the Paymesh provider package
+- Polar does not currently expose coupon webhook events through the Paymesh provider package
+
## Provider-specific behavior
### Stripe
diff --git a/packages/abacatepay/src/shared/constants.ts b/packages/abacatepay/src/shared/constants.ts
index 336acb8..93dd39f 100644
--- a/packages/abacatepay/src/shared/constants.ts
+++ b/packages/abacatepay/src/shared/constants.ts
@@ -35,7 +35,7 @@ export const ABACATEPAY_EVENTS: Record = {
'subscription.trial_started': 'subscription.created',
};
-export const ABACATEPAY_HOOKS: Record = {
+export const ABACATEPAY_HOOKS: Partial> = {
'payment.created': 'onPaymentCreated',
'payment.succeeded': 'onPaymentSucceeded',
'payment.failed': 'onPaymentFailed',
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 62a5791..7d342eb 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -80,7 +80,7 @@ paymesh trigger customer.created --client ./src/lib/paymesh.ts --data '{"email":
paymesh trigger payment.succeeded --client ./src/lib/paymesh.ts --listen http://127.0.0.1:3000/webhooks
# sends the built-in event to a running paymesh listen server instead of calling local hooks directly
-paymesh trigger onCouponRedeemed --client ./src/lib/paymesh.ts --data '{"code":"WELCOME10"}'
+paymesh trigger onCouponRedeemedCustom --client ./src/lib/paymesh.ts --data '{"code":"WELCOME10"}'
# emits a registered plugin event; plugin events require --data
```
diff --git a/packages/cli/src/commands/trigger.ts b/packages/cli/src/commands/trigger.ts
index 6305233..2804bdb 100644
--- a/packages/cli/src/commands/trigger.ts
+++ b/packages/cli/src/commands/trigger.ts
@@ -32,7 +32,7 @@ const BUILT_IN_HOOKS = {
'subscription.updated': 'onSubscriptionUpdated',
'subscription.canceled': 'onSubscriptionCanceled',
'checkout.completed': 'onCheckoutCompleted',
-} satisfies Record;
+} satisfies Partial>;
export function registerTriggerCommand(program: Command) {
program
@@ -72,7 +72,8 @@ export function registerTriggerCommand(program: Command) {
if (eventName in BUILT_IN_HOOKS) {
const data = rawData ? parseObjectJson(rawData, source) : {};
- const hook = BUILT_IN_HOOKS[eventName as PaymeshEventType];
+ const hook =
+ BUILT_IN_HOOKS[eventName as keyof typeof BUILT_IN_HOOKS]!;
const event = {
id: `evt_trigger_${randomUUID()}`,
diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts
index 6a21ca1..6e6bddf 100644
--- a/packages/cli/test/cli.test.ts
+++ b/packages/cli/test/cli.test.ts
@@ -217,7 +217,7 @@ describe('cli helpers', () => {
'node',
'paymesh',
'trigger',
- 'onCouponRedeemed',
+ 'onCouponRedeemedCustom',
'--client',
'./paymesh-client.ts',
'--data',
@@ -226,9 +226,9 @@ describe('cli helpers', () => {
}),
);
- expect(logs).toContain('onCouponRedeemed');
+ expect(logs).toContain('onCouponRedeemedCustom');
expect(logs).toContain('plugin coupons');
- expect(logs).toContain('hook onCouponRedeemed');
+ expect(logs).toContain('hook onCouponRedeemedCustom');
expect(
await fs.readFile(path.join(directory, 'trigger-log.json'), 'utf8'),
).toContain('"code":"WELCOME10"');
@@ -276,7 +276,7 @@ describe('cli helpers', () => {
'node',
'paymesh',
'trigger',
- 'onCouponRedeemed',
+ 'onCouponRedeemedCustom',
'--client',
'./paymesh-client.ts',
'--data',
@@ -435,7 +435,7 @@ describe('cli helpers', () => {
'node',
'paymesh',
'trigger',
- 'onCouponRedeemed',
+ 'onCouponRedeemedCustom',
'--client',
'./paymesh-client.ts',
]),
@@ -443,7 +443,7 @@ describe('cli helpers', () => {
).rejects.toMatchObject({
code: 'client_error',
message:
- 'Plugin event "onCouponRedeemed" requires --data or stdin with a JSON payload',
+ 'Plugin event "onCouponRedeemedCustom" requires --data or stdin with a JSON payload',
});
});
@@ -457,7 +457,7 @@ describe('cli helpers', () => {
'node',
'paymesh',
'trigger',
- 'onCouponRedeemed',
+ 'onCouponRedeemedCustom',
'--client',
'./paymesh-client.ts',
'--listen',
@@ -1454,7 +1454,7 @@ import { createClient, definePlugin, defineProvider } from ${JSON.stringify(paym
const coupons = definePlugin({
id: 'coupons',
events: {
- onCouponRedeemed: {
+ onCouponRedeemedCustom: {
description: 'Triggered when a coupon is redeemed',
},
},
@@ -1500,9 +1500,9 @@ ${exportStatement}
email: event.data.email,
}) + "\\n");
},
- onCouponRedeemed(event) {
+ onCouponRedeemedCustom(event) {
appendFileSync(${JSON.stringify(logFile)}, JSON.stringify({
- hook: 'onCouponRedeemed',
+ hook: 'onCouponRedeemedCustom',
code: event.data.code,
}) + "\\n");
},
diff --git a/packages/dodo/src/shared/constants.ts b/packages/dodo/src/shared/constants.ts
index 7573a22..2ad15c4 100644
--- a/packages/dodo/src/shared/constants.ts
+++ b/packages/dodo/src/shared/constants.ts
@@ -54,7 +54,7 @@ export const DODO_EVENTS: Record = {
'subscription.updated': 'subscription.updated',
} satisfies Record;
-export const DODO_HOOKS: Record = {
+export const DODO_HOOKS: Partial> = {
'payment.created': 'onPaymentCreated',
'payment.succeeded': 'onPaymentSucceeded',
'payment.failed': 'onPaymentFailed',
diff --git a/packages/paymesh/src/client/coupons.ts b/packages/paymesh/src/client/coupons.ts
index 50bfcfa..8e39dd6 100644
--- a/packages/paymesh/src/client/coupons.ts
+++ b/packages/paymesh/src/client/coupons.ts
@@ -40,7 +40,8 @@ export function createCouponsClient<
provider: P;
schema: ResolvedDatabaseSchema;
}) {
- const couponsRepository = database?.repositories.coupons;
+ const { coupons } = database?.repositories ?? {};
+
const getProviderCoupons = () =>
getRequiredProviderFeature(provider.coupons, provider.id, 'coupons');
@@ -50,24 +51,25 @@ export function createCouponsClient<
requestOptions?: ProviderRequestOptions,
) => {
assertCapability('coupons');
+
const providerCoupons = getProviderCoupons();
const { input, extra } = splitExtraFields(
data,
schema.tables.coupons.fields,
);
+
const coupon = await providerCoupons.create(
input as Parameters[0],
mergeOptions(requestOptions),
);
+
const resolvedCoupon = Object.assign(coupon, extra) as PaymeshCoupon<
CallIncludeRaw,
Schema
>;
- if (couponsRepository) {
- await couponsRepository.upsert(schema, resolvedCoupon);
- }
+ if (coupons) await coupons.upsert(schema, resolvedCoupon);
return resolvedCoupon;
},
@@ -76,11 +78,12 @@ export function createCouponsClient<
requestOptions?: ProviderRequestOptions,
) => {
assertCapability('coupons');
+
const providerCoupons = getProviderCoupons();
const mergedOptions = mergeOptions(requestOptions);
- if (couponsRepository) {
- const coupon = await couponsRepository.findByProviderId(
+ if (coupons) {
+ const coupon = await coupons.findByProviderId(
schema,
provider.id,
mergedOptions.sandbox ?? provider.isSandbox(),
@@ -90,9 +93,7 @@ export function createCouponsClient<
},
);
- if (coupon) {
- return coupon as PaymeshCoupon;
- }
+ if (coupon) return coupon as PaymeshCoupon;
}
return providerCoupons.get(id, mergedOptions) as Promise<
@@ -103,10 +104,11 @@ export function createCouponsClient<
options?: PaymeshCouponListOptions,
) => {
assertCapability('coupons');
+
const providerCoupons = getProviderCoupons();
- if (couponsRepository) {
- const result = await couponsRepository.list(
+ if (coupons) {
+ const result = await coupons.list(
schema,
provider.id,
options?.sandbox ?? provider.isSandbox(),
@@ -152,25 +154,26 @@ export function createCouponsClient<
requestOptions?: ProviderRequestOptions,
) => {
assertCapability('coupons');
+
const providerCoupons = getProviderCoupons();
const { input, extra } = splitExtraFields(
data,
schema.tables.coupons.fields,
);
+
const coupon = await providerCoupons.update(
id,
input as Parameters[1],
mergeOptions(requestOptions),
);
+
const resolvedCoupon = Object.assign(coupon, extra) as PaymeshCoupon<
CallIncludeRaw,
Schema
>;
- if (couponsRepository) {
- await couponsRepository.upsert(schema, resolvedCoupon);
- }
+ if (coupons) await coupons.upsert(schema, resolvedCoupon);
return resolvedCoupon;
},
@@ -197,39 +200,62 @@ export function createCouponsClient<
requestOptions?: ProviderRequestOptions,
) => {
assertCapability('coupons');
+
const providerCoupons = getProviderCoupons();
+
const result = await providerCoupons.delete(
id,
mergeOptions(requestOptions),
);
- if (couponsRepository) {
- await couponsRepository.markDeleted(schema, result);
- }
+ if (coupons) await coupons.markDeleted(schema, result);
return result;
},
archive: async (
id: string,
requestOptions?: ProviderRequestOptions,
- ) => client.delete(id, requestOptions),
+ ) => {
+ assertCapability('coupons');
+
+ const providerCoupons = getProviderCoupons();
+
+ const archivedCoupon = providerCoupons.archive
+ ? await providerCoupons.archive(id, mergeOptions(requestOptions))
+ : await providerCoupons.update(
+ id,
+ { active: false },
+ mergeOptions(requestOptions),
+ );
+
+ const resolvedCoupon = {
+ ...archivedCoupon,
+ status: 'archived',
+ active: false,
+ } as PaymeshCoupon;
+
+ if (coupons) await coupons.upsert(schema, resolvedCoupon);
+
+ return resolvedCoupon;
+ },
check: async (
data: CouponCheckData,
requestOptions?: ProviderRequestOptions,
) => {
assertCapability('coupons');
+
const providerCoupons = getProviderCoupons();
+
const result = await providerCoupons.check(
data,
mergeOptions(requestOptions),
);
- if (couponsRepository && result.coupon) {
- await couponsRepository.upsert(
+ if (coupons && result.coupon)
+ await coupons.upsert(
schema,
result.coupon as PaymeshCoupon,
);
- }
return result as PaymeshCouponCheckResult;
},
diff --git a/packages/paymesh/src/database/webhooks.ts b/packages/paymesh/src/database/webhooks.ts
index 73779fd..f5e4f04 100644
--- a/packages/paymesh/src/database/webhooks.ts
+++ b/packages/paymesh/src/database/webhooks.ts
@@ -4,7 +4,12 @@ import type {
PaymeshDatabaseDriver,
ResolvedDatabaseSchema,
} from '../types/database';
-import type { AnyPayment, PaymeshEvent, Provider } from '../types/providers';
+import type {
+ AnyPayment,
+ Coupon,
+ PaymeshEvent,
+ Provider,
+} from '../types/providers';
interface HandleClientWebhookOptions {
provider: Provider;
@@ -65,7 +70,14 @@ export async function handleClientWebhook({
};
}
- const event = handled.event as PaymeshEvent