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.
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/apps/web/.source/browser.ts b/apps/web/.source/browser.ts
index 7cf14f5..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"), "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"), "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 481ba5c..69700dc 100644
--- a/apps/web/.source/server.ts
+++ b/apps/web/.source/server.ts
@@ -1,36 +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_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/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_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"
@@ -51,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, "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, "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/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/api.mdx b/apps/web/content/docs/concepts/api.mdx
index cb38d36..ab8775d 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,28 @@ 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.activate()`
+- `coupons.deactivate()`
+- `coupons.delete()`
+- `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.
@@ -87,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
new file mode 100644
index 0000000..4f8b4ed
--- /dev/null
+++ b/apps/web/content/docs/concepts/coupons.mdx
@@ -0,0 +1,614 @@
+---
+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.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:
+
+- `@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"
+ | "archived"
+ | "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.
+
+## 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)` archives the coupon and returns a normalized `Coupon`
+
+That means:
+
+- `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"
+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`
+
+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
+
+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](/docs/providers/stripe).
+
+### 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](/docs/providers/polar).
+
+## 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/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/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?`
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..4fd5834 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:
@@ -433,6 +451,7 @@ Supported commands:
- [Stripe](/docs/providers/stripe)
- [Polar](/docs/providers/polar)
- [AbacatePay](/docs/providers/abacatepay)
+- [Dodo](/docs/providers/dodo)
### Adapters
@@ -475,7 +494,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..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' },
{
@@ -114,8 +119,8 @@ export const docsNavigation: DocNavGroup[] = [
},
{
label: 'Dodo',
- description: 'Planned provider',
- status: 'planned',
+ href: '/docs/providers/dodo',
+ description: 'Catalog-driven Dodo Payments provider.',
icon: 'dodo',
},
],
diff --git a/apps/web/src/lib/docs.tsx b/apps/web/src/lib/docs.tsx
index 3021d09..d8b0bb3 100644
--- a/apps/web/src/lib/docs.tsx
+++ b/apps/web/src/lib/docs.tsx
@@ -281,6 +281,14 @@ const fetched = await client.customers.get(customer.id);
await client.customers.delete(fetched.id);`;
+const dodoCustomerSnippet = `const customer = await client.customers.upsert({
+ email: "billing@example.com",
+ externalId: "org_42",
+ name: "ACME Corp",
+});
+
+const fetched = await client.customers.get(customer.id);`;
+
const paymentCreateShapeSnippet = `type PaymentCreateData = {
amount: number;
currency: string;
@@ -429,6 +437,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 +876,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 +1953,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 +2175,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 +2383,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.',
@@ -2485,7 +2597,7 @@ const docsPages: DocPage[] = [
state stay inside one integration boundary.
@@ -3277,10 +3389,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 +3814,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 +3965,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,
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"],
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/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/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/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/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts
index 733e07a..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',
@@ -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 () => {
@@ -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/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..6c20f2a
--- /dev/null
+++ b/packages/dodo/src/index.ts
@@ -0,0 +1,340 @@
+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.');
+ }
+ if (!('data' in event) || !isRecord(event.data))
+ throw new PaymeshError({
+ code: 'webhook_parse_error',
+ message:
+ 'Dodo webhook payload must include an object "data" field.',
+ provider: 'dodo',
+ });
+
+ 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..a1928ca
--- /dev/null
+++ b/packages/dodo/src/shared/constants.ts
@@ -0,0 +1,71 @@
+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',
+ '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: Partial> = {
+ '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..d7141ae
--- /dev/null
+++ b/packages/dodo/src/shared/sync.ts
@@ -0,0 +1,111 @@
+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);
+
+ await database.repositories.invoices.upsert(schema, normalized);
+
+ if ('method' in normalizedPayment && normalizedPayment.method === 'pix') {
+ await database.repositories.pix.upsert(
+ schema,
+ normalized as Extract,
+ );
+ }
+
+ 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;
+};
diff --git a/packages/dodo/src/shared/utils.ts b/packages/dodo/src/shared/utils.ts
new file mode 100644
index 0000000..c430f53
--- /dev/null
+++ b/packages/dodo/src/shared/utils.ts
@@ -0,0 +1,249 @@
+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',
+ });
+ }
+
+ if (productIds.some((id) => typeof id !== 'string' || id.trim().length === 0))
+ throw new PaymeshError({
+ code: 'invalid_request',
+ message: 'Provider "dodo" requires non-empty "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',
+ });
+
+ if (
+ typeof input.amount === 'number' &&
+ (!Number.isFinite(input.amount) || input.amount <= 0)
+ )
+ throw new PaymeshError({
+ code: 'invalid_request',
+ message:
+ 'Provider "dodo" requires "amount" to be a positive finite number.',
+ 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..f8dfa58
--- /dev/null
+++ b/packages/dodo/src/shared/webhooks.ts
@@ -0,0 +1,125 @@
+import type { PaymeshEventType } from 'paymesh';
+import { PaymeshError, 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 === 'subscription.active' ||
+ event.type === 'subscription.renewed' ||
+ event.type === 'subscription.on_hold' ||
+ event.type === 'subscription.paused' ||
+ event.type === 'subscription.cancelled' ||
+ event.type === 'subscription.failed' ||
+ event.type === 'subscription.expired' ||
+ event.type === 'subscription.plan_changed' ||
+ event.type === 'subscription.updated'
+ );
+}
+
+export function resolveDodoWebhookType(type: string): PaymeshEventType {
+ const mapped = DODO_EVENTS[type];
+ if (!mapped) {
+ throw new PaymeshError({
+ code: 'webhook_parse_error',
+ message: `Unsupported Dodo webhook type: ${type}`,
+ provider: 'dodo',
+ });
+ }
+
+ return mapped;
+}
+
+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..6052630
--- /dev/null
+++ b/packages/dodo/test/dodo.test.ts
@@ -0,0 +1,276 @@
+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',
+ });
+
+ await 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"',
+ });
+
+ await 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.',
+ });
+
+ await 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.',
+ });
+
+ await expect(
+ provider.payments.create({
+ productIds: [''],
+ customer: {
+ email: 'ana@example.com',
+ },
+ }),
+ ).rejects.toMatchObject({
+ code: 'invalid_request',
+ message: 'Provider "dodo" requires non-empty "productIds".',
+ });
+
+ await expect(
+ provider.payments.create({
+ productIds: ['prod_123'],
+ amount: Number.NaN,
+ customer: {
+ email: 'ana@example.com',
+ },
+ }),
+ ).rejects.toMatchObject({
+ code: 'invalid_request',
+ message:
+ 'Provider "dodo" requires "amount" to be a positive finite number.',
+ });
+ });
+
+ 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..dc38548
--- /dev/null
+++ b/packages/dodo/test/webhooks.test.ts
@@ -0,0 +1,253 @@
+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);
+ });
+
+ test('rejects malformed webhook payload data and unsupported event types', async () => {
+ const provider = dodo({
+ webhookSecret: 'whsec_c2VjcmV0X2Rlc2lnbg==',
+ });
+
+ await expect(
+ provider.webhooks?.handle({
+ request: new Request('https://app.test/webhooks', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ },
+ body: JSON.stringify({
+ business_id: 'biz_123',
+ type: 'payment.succeeded',
+ timestamp: new Date().toISOString(),
+ data: 'invalid',
+ }),
+ }),
+ }),
+ ).rejects.toMatchObject({
+ code: 'webhook_parse_error',
+ message: 'Dodo webhook payload must include an object "data" field.',
+ });
+
+ await expect(
+ provider.webhooks?.handle({
+ request: new Request('https://app.test/webhooks', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ },
+ body: JSON.stringify({
+ business_id: 'biz_123',
+ type: 'refund.failed',
+ timestamp: new Date().toISOString(),
+ data: {
+ refund_id: 'ref_123',
+ payment_id: 'pay_123',
+ status: 'failed',
+ },
+ }),
+ }),
+ }),
+ ).rejects.toMatchObject({
+ code: 'webhook_parse_error',
+ message: 'Unsupported Dodo webhook type: refund.failed',
+ });
+ });
+});
+
+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',
+});
diff --git a/packages/drizzle/src/repositories.ts b/packages/drizzle/src/repositories.ts
index 6b371e6..c33ce5f 100644
--- a/packages/drizzle/src/repositories.ts
+++ b/packages/drizzle/src/repositories.ts
@@ -161,6 +161,7 @@ export function createRepositories(
const cursor = decodeCustomerCursor(
options?.before ?? options?.after,
options?.before ? 'before' : 'after',
+ 'coupon',
);
const includeRaw = options?.includeRaw;
const [totalRow] = await executor.query<{ total: number | string }>({
@@ -279,6 +280,348 @@ 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}`);
+ }
+ if (options?.status) {
+ countParams.push(options.status);
+ countFilters.push(`status = $${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/drizzle/src/shared/cursor.ts b/packages/drizzle/src/shared/cursor.ts
index ad34ab8..79b7b58 100644
--- a/packages/drizzle/src/shared/cursor.ts
+++ b/packages/drizzle/src/shared/cursor.ts
@@ -35,13 +35,14 @@ export function encodeCustomerCursor(row: {
export function decodeCustomerCursor(
cursorValue: string | undefined,
mode: 'after' | 'before',
+ entity: 'customer' | 'coupon' = 'customer',
): CustomerListCursor | null {
if (!cursorValue) return null;
if (!cursorValue.startsWith('pc1.')) {
throw new PaymeshError({
code: 'invalid_request',
- message: 'Invalid customer list cursor',
+ message: `Invalid ${entity} list cursor`,
});
}
@@ -71,7 +72,7 @@ export function decodeCustomerCursor(
} catch {
throw new PaymeshError({
code: 'invalid_request',
- message: 'Invalid customer list cursor',
+ message: `Invalid ${entity} list cursor`,
});
}
}
diff --git a/packages/memory/src/repositories.ts b/packages/memory/src/repositories.ts
index 0b86b9f..f701d8c 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,
@@ -151,6 +152,7 @@ export function createRepositories(
const cursor = decodeCustomerCursor(
listOptions?.before ?? listOptions?.after,
listOptions?.before ? 'before' : 'after',
+ 'coupon',
);
const filtered = [...options.getState().customers.values()]
.filter(
@@ -235,6 +237,173 @@ 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) &&
+ (listOptions?.status
+ ? coupon.status === listOptions.status
+ : 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 +730,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/shared/cursor.ts b/packages/memory/src/shared/cursor.ts
index 9d245e1..3338e52 100644
--- a/packages/memory/src/shared/cursor.ts
+++ b/packages/memory/src/shared/cursor.ts
@@ -40,13 +40,14 @@ export function encodeCustomerCursor(row: {
export function decodeCustomerCursor(
cursorValue: string | undefined,
mode: 'after' | 'before',
+ entity: 'customer' | 'coupon' = 'customer',
): CustomerListCursor | null {
if (!cursorValue) return null;
if (!cursorValue.startsWith('pc1.'))
throw new PaymeshError({
code: 'invalid_request',
- message: 'Invalid customer list cursor',
+ message: `Invalid ${entity} list cursor`,
});
try {
@@ -73,7 +74,7 @@ export function decodeCustomerCursor(
throw new PaymeshError({
cause: err,
code: 'invalid_request',
- message: 'Invalid customer list cursor',
+ message: `Invalid ${entity} list cursor`,
});
}
}
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/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,
diff --git a/packages/paymesh/src/client/coupons.ts b/packages/paymesh/src/client/coupons.ts
new file mode 100644
index 0000000..f815470
--- /dev/null
+++ b/packages/paymesh/src/client/coupons.ts
@@ -0,0 +1,267 @@
+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 { coupons } = database?.repositories ?? {};
+
+ const getProviderCoupons = () =>
+ getRequiredProviderFeature(provider.coupons, provider.id, 'coupons');
+
+ const client = {
+ 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 (coupons) await coupons.upsert(schema, resolvedCoupon);
+
+ return resolvedCoupon;
+ },
+ get: async (
+ id: string,
+ requestOptions?: ProviderRequestOptions,
+ ) => {
+ assertCapability('coupons');
+
+ const providerCoupons = getProviderCoupons();
+ const mergedOptions = mergeOptions(requestOptions);
+
+ if (coupons) {
+ const coupon = await coupons.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 (coupons) {
+ const result = await coupons.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,
+ status: options?.status,
+ },
+ );
+
+ 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,
+ status: options?.status,
+ },
+ 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 (coupons) await coupons.upsert(schema, resolvedCoupon);
+
+ 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,
+ ) => {
+ assertCapability('coupons');
+
+ const providerCoupons = getProviderCoupons();
+
+ const result = await providerCoupons.delete(
+ id,
+ mergeOptions(requestOptions),
+ );
+
+ if (coupons && result.deleted) await coupons.markDeleted(schema, result);
+
+ return result;
+ },
+ archive: async (
+ id: string,
+ requestOptions?: ProviderRequestOptions,
+ ) => {
+ 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 (coupons && result.coupon)
+ await coupons.upsert(
+ schema,
+ result.coupon as PaymeshCoupon,
+ );
+
+ return result as PaymeshCouponCheckResult;
+ },
+ };
+
+ return client;
+}
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/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;
+ const originalType = handled.event.type;
+ const event = database
+ ? ((await normalizeWebhookEvent(
+ database,
+ schema,
+ handled.event as PaymeshEvent,
+ )) as PaymeshEvent)
+ : (handled.event as PaymeshEvent);
const deliveryId = handled.deliveryId ?? event.id;
const acquired = database
? await database.repositories.webhookEvents.acquire(
@@ -93,15 +105,19 @@ export async function handleClientWebhook({
}
if (dispatchHook) {
+ const hookName =
+ event.type === originalType
+ ? (handled.hook ?? resolveBuiltInHookName(event.type))
+ : resolveBuiltInHookName(event.type);
const context = {
request: request.clone(),
deliveryId,
dispatchedAt: new Date().toISOString(),
- hook: handled.hook,
+ hook: hookName,
};
const hookedEvent = withHookContext(event, context);
const specificHook =
- handled.hook && hasHook?.(handled.hook) ? handled.hook : undefined;
+ hookName && hasHook?.(hookName) ? hookName : undefined;
if (specificHook) {
await dispatchHook(specificHook, hookedEvent);
@@ -161,6 +177,31 @@ async function persistEvent(
event: PaymeshEvent,
) {
switch (event.type) {
+ case 'coupon.created':
+ case 'coupon.updated':
+ case 'coupon.archived':
+ case 'coupon.redeemed':
+ case 'coupon.redemption_failed':
+ case 'coupon.expired':
+ await database.repositories.coupons?.upsert(
+ schema,
+ event.data as Parameters<
+ NonNullable<
+ PaymeshDatabaseDriver['repositories']['coupons']
+ >['upsert']
+ >[1],
+ );
+ return;
+ case 'coupon.deleted':
+ await database.repositories.coupons?.markDeleted(
+ schema,
+ event.data as Parameters<
+ NonNullable<
+ PaymeshDatabaseDriver['repositories']['coupons']
+ >['markDeleted']
+ >[1],
+ );
+ return;
case 'customer.created':
case 'customer.updated':
await database.repositories.customers.upsert(
@@ -220,3 +261,90 @@ async function persistPayment(
await Promise.all(tasks);
}
+
+async function normalizeWebhookEvent(
+ database: PaymeshDatabaseDriver,
+ schema: ResolvedDatabaseSchema,
+ event: PaymeshEvent,
+) {
+ if (!database.repositories.coupons) return event;
+
+ if (event.type === 'coupon.created') return event;
+ if (event.type === 'coupon.deleted') return event;
+ if (event.type !== 'coupon.updated') return event;
+
+ const coupon = event.data as Coupon;
+ const previous = await database.repositories.coupons.findByProviderId(
+ schema,
+ coupon.provider,
+ coupon.sandbox,
+ coupon.id,
+ { includeRaw: true },
+ );
+
+ if (!previous) return event;
+
+ if (coupon.redemptions.count > previous.redemptions.count) {
+ return { ...event, type: 'coupon.redeemed' as const };
+ }
+
+ if (coupon.status === 'expired' && previous.status !== 'expired') {
+ return { ...event, type: 'coupon.expired' as const };
+ }
+
+ if (
+ coupon.status === 'archived' ||
+ (coupon.active === false &&
+ previous.active !== false &&
+ previous.status !== 'archived')
+ ) {
+ return { ...event, type: 'coupon.archived' as const };
+ }
+
+ return event;
+}
+
+function resolveBuiltInHookName(type: string) {
+ switch (type) {
+ case 'payment.created':
+ return 'onPaymentCreated';
+ case 'payment.succeeded':
+ return 'onPaymentSucceeded';
+ case 'payment.failed':
+ return 'onPaymentFailed';
+ case 'payment.canceled':
+ return 'onPaymentCanceled';
+ case 'payment.refunded':
+ return 'onPaymentRefunded';
+ case 'coupon.created':
+ return 'onCouponCreated';
+ case 'coupon.updated':
+ return 'onCouponUpdated';
+ case 'coupon.deleted':
+ return 'onCouponDeleted';
+ case 'coupon.archived':
+ return 'onCouponArchived';
+ case 'coupon.redeemed':
+ return 'onCouponRedeemed';
+ case 'coupon.redemption_failed':
+ return 'onCouponRedemptionFailed';
+ case 'coupon.expired':
+ return 'onCouponExpired';
+ case 'customer.created':
+ return 'onCustomerCreated';
+ case 'customer.updated':
+ return 'onCustomerUpdated';
+ case 'customer.deleted':
+ return 'onCustomerDeleted';
+ case 'subscription.created':
+ return 'onSubscriptionCreated';
+ case 'subscription.updated':
+ return 'onSubscriptionUpdated';
+ case 'subscription.canceled':
+ return 'onSubscriptionCanceled';
+ case 'checkout.completed':
+ return 'onCheckoutCompleted';
+ default:
+ return undefined;
+ }
+}
diff --git a/packages/paymesh/src/plugins/runtime.ts b/packages/paymesh/src/plugins/runtime.ts
index 179f654..3ff4e17 100644
--- a/packages/paymesh/src/plugins/runtime.ts
+++ b/packages/paymesh/src/plugins/runtime.ts
@@ -29,6 +29,13 @@ const BUILT_IN_HOOK_NAMES = [
'onPaymentFailed',
'onPaymentCanceled',
'onPaymentRefunded',
+ 'onCouponCreated',
+ 'onCouponUpdated',
+ 'onCouponDeleted',
+ 'onCouponArchived',
+ 'onCouponRedeemed',
+ 'onCouponRedemptionFailed',
+ 'onCouponExpired',
'onCustomerCreated',
'onCustomerUpdated',
'onCustomerDeleted',
diff --git a/packages/paymesh/src/types/client.ts b/packages/paymesh/src/types/client.ts
index 2092ada..9cd4c3a 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,
@@ -56,6 +64,18 @@ export type CustomerDeletedEvent =
type: 'customer.deleted';
};
+/** Coupon event narrowed to a specific event type. */
+export type CouponEvent<
+ Type extends PaymeshEventType,
+ IncludeRaw extends boolean = false,
+> = PaymeshEvent, IncludeRaw> & { type: Type };
+
+/** Coupon deleted event with a normalized payload. */
+export type CouponDeletedEvent =
+ PaymeshEvent, IncludeRaw> & {
+ type: 'coupon.deleted';
+ };
+
/** Catch-all event type used when the payload is not strongly typed. */
export type UnknownEvent<
Type extends PaymeshEventType,
@@ -88,6 +108,13 @@ export type AnyWebhookEvent =
| PaymentEvent<'payment.failed', IncludeRaw>
| PaymentEvent<'payment.canceled', IncludeRaw>
| PaymentEvent<'payment.refunded', IncludeRaw>
+ | CouponEvent<'coupon.created', IncludeRaw>
+ | CouponEvent<'coupon.updated', IncludeRaw>
+ | CouponDeletedEvent
+ | CouponEvent<'coupon.archived', IncludeRaw>
+ | CouponEvent<'coupon.redeemed', IncludeRaw>
+ | CouponEvent<'coupon.redemption_failed', IncludeRaw>
+ | CouponEvent<'coupon.expired', IncludeRaw>
| CustomerEvent<'customer.created', IncludeRaw>
| CustomerEvent<'customer.updated', IncludeRaw>
| CustomerDeletedEvent
@@ -115,6 +142,27 @@ export interface BuiltInPaymeshHooks {
onPaymentRefunded?: PaymeshHook<
WebhookHookEvent>
>;
+ onCouponCreated?: PaymeshHook<
+ WebhookHookEvent>
+ >;
+ onCouponUpdated?: PaymeshHook<
+ WebhookHookEvent>
+ >;
+ onCouponDeleted?: PaymeshHook<
+ WebhookHookEvent>
+ >;
+ onCouponArchived?: PaymeshHook<
+ WebhookHookEvent>
+ >;
+ onCouponRedeemed?: PaymeshHook<
+ WebhookHookEvent>
+ >;
+ onCouponRedemptionFailed?: PaymeshHook<
+ WebhookHookEvent>
+ >;
+ onCouponExpired?: PaymeshHook<
+ WebhookHookEvent>
+ >;
onCustomerCreated?: PaymeshHook<
WebhookHookEvent>
>;
@@ -283,6 +331,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 & Partial>
+>;
+
+/** 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 +420,60 @@ 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>;
+ /** 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,
+ options?: ProviderRequestOptions,
+ ): Promise<
+ CouponCheckResult>
+ >;
+}
+
/** Full Paymesh client returned by `createClient`. */
export interface PaymeshClient<
IncludeRaw extends boolean = false,
@@ -362,6 +498,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..ce96a0d 100644
--- a/packages/paymesh/src/types/database.ts
+++ b/packages/paymesh/src/types/database.ts
@@ -1,11 +1,15 @@
import type {
AnyPayment,
BaseAnyPayment,
+ BaseCoupon,
+ BaseCouponDeleteResult,
BaseCustomer,
BaseCustomerDeleteResult,
BasePayment,
BasePaymeshEvent,
BasePix,
+ Coupon,
+ CouponStatus,
Customer,
Pix,
ProviderCatalogPrice,
@@ -17,6 +21,7 @@ import type {
*/
export type DatabaseTableKey =
| 'customers'
+ | 'coupons'
| 'pix'
| 'checkouts'
| 'invoices'
@@ -421,6 +426,45 @@ 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;
+ /** Filter by normalized coupon status. */
+ status?: CouponStatus;
+ /** 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 +524,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 +719,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..c66922c 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,213 @@ export type CustomerDeleteResult = WithRaw<
IncludeRaw
>;
+/** Common coupon status values normalized across providers. */
+export type CouponStatus =
+ | 'active'
+ | 'inactive'
+ | 'archived'
+ | '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. */
@@ -294,6 +505,12 @@ export interface ProviderDashboardSyncInput {
schema: ResolvedDatabaseSchema;
}
+/** Normalized subscription payload returned by provider dashboard sync helpers. */
+export interface ProviderDashboardSubscriptionRecord extends RawObject {
+ /** Provider resource id, when exposed by the normalized payload. */
+ id?: string;
+}
+
/** Dashboard adapter contract exposed by a provider. */
export interface ProviderDashboardAdapter {
/** Fetches the provider balance, when the provider exposes one. */
@@ -315,7 +532,7 @@ export interface ProviderDashboardAdapter {
/** Synchronizes a subscription or equivalent resource. */
syncSubscription?(
input: ProviderDashboardSyncInput,
- ): Promise | null>;
+ ): Promise;
}
/** Payment creation contract. */
@@ -360,6 +577,46 @@ 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>;
+ /** Archives a coupon or promotion code without deleting its local mapping. */
+ archive?(
+ id: string,
+ 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;
@@ -372,6 +629,13 @@ export type PaymeshEventType =
| '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'
@@ -456,6 +720,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..f5a6a55 100644
--- a/packages/paymesh/test/client.test.ts
+++ b/packages/paymesh/test/client.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, test } from 'bun:test';
import {
+ type Coupon,
+ type CouponCheckData,
+ type CouponCheckResult,
+ type CouponCreateData,
+ type CouponDeleteResult,
type Customer,
createClient,
defineDatabaseAdapter,
@@ -156,6 +161,366 @@ 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('provides coupon lifecycle helpers', async () => {
+ const updates: Array = [];
+ const archives: string[] = [];
+ 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,
+ );
+ },
+ onCouponArchive: async (id, options) => {
+ archives.push(id);
+ return withRaw(
+ {
+ id,
+ provider: 'stub',
+ sandbox: false,
+ code: 'WELCOME10',
+ status: 'archived',
+ active: false,
+ 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');
+ const deleted = await client.coupons.delete('promo_123');
+
+ expect(updates).toEqual([{ active: true }, { active: false }]);
+ expect(archives).toEqual(['promo_123']);
+ expect(deletes).toEqual(['promo_123']);
+ expect(activated.active).toBe(true);
+ expect(deactivated.active).toBe(false);
+ expect(archived.status).toBe('archived');
+ expect(archived.active).toBe(false);
+ expect(deleted.deleted).toBe(true);
+ });
+
+ test('forwards coupon status filters to provider and repository lists', async () => {
+ let providerListOptions: Record | undefined;
+ let repositoryListOptions: Record | undefined;
+
+ const provider = createStubProvider({
+ onCouponList: async (options, requestOptions) => {
+ providerListOptions = {
+ ...(options ?? {}),
+ includeRaw: requestOptions?.includeRaw,
+ sandbox: requestOptions?.sandbox,
+ };
+
+ return {
+ data: [
+ withRaw(
+ {
+ id: 'promo_123',
+ provider: 'stub',
+ sandbox: false,
+ code: 'WELCOME10',
+ status: 'expired' as const,
+ discount: { type: 'percentage' as const, value: 10 },
+ redemptions: { count: 0, max: null },
+ },
+ { id: 'raw_coupon_123' },
+ requestOptions?.includeRaw,
+ ),
+ ],
+ total: 1,
+ previous: null,
+ next: null,
+ };
+ },
+ });
+
+ const database = defineDatabaseAdapter({
+ id: 'mock',
+ dialect: 'postgres',
+ persistRaw: false,
+ repositories: {
+ customers: {
+ async findByProviderId() {
+ return null;
+ },
+ async list() {
+ return { data: [], total: 0, previous: null, next: null };
+ },
+ async upsert() {},
+ async markDeleted() {},
+ },
+ coupons: {
+ async findByProviderId() {
+ return null;
+ },
+ async findByCode() {
+ return null;
+ },
+ async list(_schema, _provider, _sandbox, options) {
+ repositoryListOptions = { ...(options ?? {}) };
+ return { data: [], total: 0, previous: null, next: null };
+ },
+ async upsert() {},
+ async markDeleted() {},
+ },
+ pix: {
+ async findByProviderId() {
+ return null;
+ },
+ async upsert() {},
+ },
+ checkouts: {
+ async findByProviderId() {
+ return null;
+ },
+ async upsert() {},
+ },
+ invoices: {
+ async findByProviderId() {
+ return null;
+ },
+ async upsert() {},
+ },
+ subscriptions: {
+ async findByProviderId() {
+ return null;
+ },
+ async upsert() {},
+ },
+ 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() {
+ return [] as Row[];
+ },
+ async execute() {},
+ async transaction(callback) {
+ return callback(database);
+ },
+ });
+
+ await createClient({ provider }).coupons.list({
+ status: 'expired',
+ active: false,
+ code: 'WELCOME10',
+ sandbox: true,
+ });
+ await createClient({ provider, database }).coupons.list({
+ status: 'expired',
+ active: false,
+ code: 'WELCOME10',
+ });
+
+ expect(providerListOptions).toMatchObject({
+ status: 'expired',
+ active: false,
+ code: 'WELCOME10',
+ includeRaw: false,
+ sandbox: true,
+ });
+ expect(repositoryListOptions).toMatchObject({
+ status: 'expired',
+ active: false,
+ code: 'WELCOME10',
+ includeRaw: false,
+ });
+ });
+
+ test('marks coupons deleted locally only after provider deletion succeeds', async () => {
+ let markDeletedCalls = 0;
+
+ const database = defineDatabaseAdapter({
+ id: 'mock',
+ dialect: 'postgres',
+ persistRaw: false,
+ repositories: {
+ customers: {
+ async findByProviderId() {
+ return null;
+ },
+ async list() {
+ return { data: [], total: 0, previous: null, next: null };
+ },
+ async upsert() {},
+ async markDeleted() {},
+ },
+ coupons: {
+ async findByProviderId() {
+ return null;
+ },
+ async findByCode() {
+ return null;
+ },
+ async list() {
+ return { data: [], total: 0, previous: null, next: null };
+ },
+ async upsert() {},
+ async markDeleted() {
+ markDeletedCalls += 1;
+ },
+ },
+ pix: {
+ async findByProviderId() {
+ return null;
+ },
+ async upsert() {},
+ },
+ checkouts: {
+ async findByProviderId() {
+ return null;
+ },
+ async upsert() {},
+ },
+ invoices: {
+ async findByProviderId() {
+ return null;
+ },
+ async upsert() {},
+ },
+ subscriptions: {
+ async findByProviderId() {
+ return null;
+ },
+ async upsert() {},
+ },
+ 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() {
+ return [] as Row[];
+ },
+ async execute() {},
+ async transaction(callback) {
+ return callback(database);
+ },
+ });
+
+ const client = createClient({
+ provider: createStubProvider({
+ onCouponDelete: async (id, options) =>
+ withRaw(
+ {
+ id,
+ provider: 'stub',
+ sandbox: false,
+ deleted: false,
+ code: 'WELCOME10',
+ },
+ { id: 'raw_coupon_123' },
+ options?.includeRaw,
+ ),
+ }),
+ database,
+ });
+
+ const result = await client.coupons.delete('promo_123');
+
+ expect(result.deleted).toBe(false);
+ expect(markDeletedCalls).toBe(0);
+ });
+
test('delegates client.isSandbox() to the provider', () => {
const client = createClient({
provider: createStubProvider({ sandbox: true }),
@@ -743,6 +1108,50 @@ describe('client', () => {
expectType(client);
});
+ test('types coupon webhook hooks with normalized coupon payloads', () => {
+ const client = createClient({
+ provider: createStubProvider(),
+ hooks: {
+ onCouponCreated(event) {
+ expectType<'coupon.created'>(event.type);
+ expectType(event.data.code);
+ expectType(event.data.redemptions.count);
+ },
+ onCouponArchived(event) {
+ expectType<'coupon.archived'>(event.type);
+ expectType<
+ | 'archived'
+ | 'active'
+ | 'inactive'
+ | 'scheduled'
+ | 'expired'
+ | 'deleted'
+ >(event.data.status);
+ },
+ onCouponDeleted(event) {
+ expectType<'coupon.deleted'>(event.type);
+ expectType(event.data.deleted);
+ // @ts-expect-error deleted coupon payload does not expose discount details
+ expectType(event.data.discount.value);
+ },
+ onCouponRedeemed(event) {
+ expectType<'coupon.redeemed'>(event.type);
+ expectType(event.data.redemptions.count);
+ },
+ onCouponRedemptionFailed(event) {
+ expectType<'coupon.redemption_failed'>(event.type);
+ expectType(event.data.code);
+ },
+ onCouponExpired(event) {
+ expectType<'coupon.expired'>(event.type);
+ expectType(event.data.expiresAt);
+ },
+ },
+ });
+
+ expectType