From 7b88229defb66c7fd02d669eb103435362cd53d8 Mon Sep 17 00:00:00 2001 From: almeida Date: Sat, 13 Jun 2026 10:52:38 -0300 Subject: [PATCH 01/15] feat(web): add Dodo Payments page --- apps/web/.source/browser.ts | 2 +- apps/web/.source/server.ts | 70 +++--- apps/web/content/docs/basic-usage.mdx | 2 +- .../docs/concepts/payment-providers.mdx | 13 ++ apps/web/content/docs/concepts/pix.mdx | 5 + apps/web/content/docs/installation.mdx | 24 ++- apps/web/content/docs/introduction.mdx | 6 +- apps/web/content/docs/providers/dodo.mdx | 203 ++++++++++++++++++ apps/web/public/llms.txt | 26 ++- apps/web/src/app/page.tsx | 8 +- .../src/components/docs/mdx-components.tsx | 1 + apps/web/src/lib/docs-navigation.ts | 4 +- 12 files changed, 314 insertions(+), 50 deletions(-) create mode 100644 apps/web/content/docs/providers/dodo.mdx diff --git a/apps/web/.source/browser.ts b/apps/web/.source/browser.ts index 7cf14f5..ecc2455 100644 --- a/apps/web/.source/browser.ts +++ b/apps/web/.source/browser.ts @@ -7,6 +7,6 @@ const create = browser(); const browserCollections = { - docs: create.doc("docs", {"basic-usage.mdx": () => import("../content/docs/basic-usage.mdx?collection=docs"), "comparison.mdx": () => import("../content/docs/comparison.mdx?collection=docs"), "installation.mdx": () => import("../content/docs/installation.mdx?collection=docs"), "introduction.mdx": () => import("../content/docs/introduction.mdx?collection=docs"), "mcp.mdx": () => import("../content/docs/mcp.mdx?collection=docs"), "adapters/elysia.mdx": () => import("../content/docs/adapters/elysia.mdx?collection=docs"), "adapters/express.mdx": () => import("../content/docs/adapters/express.mdx?collection=docs"), "adapters/fastify.mdx": () => import("../content/docs/adapters/fastify.mdx?collection=docs"), "adapters/hono.mdx": () => import("../content/docs/adapters/hono.mdx?collection=docs"), "adapters/next.mdx": () => import("../content/docs/adapters/next.mdx?collection=docs"), "ai-resources/skills.mdx": () => import("../content/docs/ai-resources/skills.mdx?collection=docs"), "database/drizzle.mdx": () => import("../content/docs/database/drizzle.mdx?collection=docs"), "database/overview.mdx": () => import("../content/docs/database/overview.mdx?collection=docs"), "database/postgres.mdx": () => import("../content/docs/database/postgres.mdx?collection=docs"), "database/prisma.mdx": () => import("../content/docs/database/prisma.mdx?collection=docs"), "concepts/api.mdx": () => import("../content/docs/concepts/api.mdx?collection=docs"), "concepts/client.mdx": () => import("../content/docs/concepts/client.mdx?collection=docs"), "concepts/customers.mdx": () => import("../content/docs/concepts/customers.mdx?collection=docs"), "concepts/hooks.mdx": () => import("../content/docs/concepts/hooks.mdx?collection=docs"), "concepts/payment-providers.mdx": () => import("../content/docs/concepts/payment-providers.mdx?collection=docs"), "concepts/pix.mdx": () => import("../content/docs/concepts/pix.mdx?collection=docs"), "concepts/sandbox.mdx": () => import("../content/docs/concepts/sandbox.mdx?collection=docs"), "concepts/typescript.mdx": () => import("../content/docs/concepts/typescript.mdx?collection=docs"), "guides/create-a-database-adapter.mdx": () => import("../content/docs/guides/create-a-database-adapter.mdx?collection=docs"), "guides/create-a-provider.mdx": () => import("../content/docs/guides/create-a-provider.mdx?collection=docs"), "guides/create-your-first-plugin.mdx": () => import("../content/docs/guides/create-your-first-plugin.mdx?collection=docs"), "guides/database.mdx": () => import("../content/docs/guides/database.mdx?collection=docs"), "guides/optimize-performance.mdx": () => import("../content/docs/guides/optimize-performance.mdx?collection=docs"), "guides/plugins.mdx": () => import("../content/docs/guides/plugins.mdx?collection=docs"), "guides/provider-selection.mdx": () => import("../content/docs/guides/provider-selection.mdx?collection=docs"), "guides/testing.mdx": () => import("../content/docs/guides/testing.mdx?collection=docs"), "guides/webhooks-at-scale.mdx": () => import("../content/docs/guides/webhooks-at-scale.mdx?collection=docs"), "guides/webhooks.mdx": () => import("../content/docs/guides/webhooks.mdx?collection=docs"), "plugins/audit-logs.mdx": () => import("../content/docs/plugins/audit-logs.mdx?collection=docs"), "plugins/dash.mdx": () => import("../content/docs/plugins/dash.mdx?collection=docs"), "plugins/overview.mdx": () => import("../content/docs/plugins/overview.mdx?collection=docs"), "providers/abacatepay.mdx": () => import("../content/docs/providers/abacatepay.mdx?collection=docs"), "providers/polar.mdx": () => import("../content/docs/providers/polar.mdx?collection=docs"), "providers/stripe.mdx": () => import("../content/docs/providers/stripe.mdx?collection=docs"), "reference/cli.mdx": () => import("../content/docs/reference/cli.mdx?collection=docs"), "reference/client-options.mdx": () => import("../content/docs/reference/client-options.mdx?collection=docs"), "reference/database-schema.mdx": () => import("../content/docs/reference/database-schema.mdx?collection=docs"), "reference/errors.mdx": () => import("../content/docs/reference/errors.mdx?collection=docs"), "reference/plugin-api.mdx": () => import("../content/docs/reference/plugin-api.mdx?collection=docs"), }), + docs: create.doc("docs", {"basic-usage.mdx": () => import("../content/docs/basic-usage.mdx?collection=docs"), "comparison.mdx": () => import("../content/docs/comparison.mdx?collection=docs"), "installation.mdx": () => import("../content/docs/installation.mdx?collection=docs"), "introduction.mdx": () => import("../content/docs/introduction.mdx?collection=docs"), "mcp.mdx": () => import("../content/docs/mcp.mdx?collection=docs"), "adapters/elysia.mdx": () => import("../content/docs/adapters/elysia.mdx?collection=docs"), "adapters/express.mdx": () => import("../content/docs/adapters/express.mdx?collection=docs"), "adapters/fastify.mdx": () => import("../content/docs/adapters/fastify.mdx?collection=docs"), "adapters/hono.mdx": () => import("../content/docs/adapters/hono.mdx?collection=docs"), "adapters/next.mdx": () => import("../content/docs/adapters/next.mdx?collection=docs"), "ai-resources/skills.mdx": () => import("../content/docs/ai-resources/skills.mdx?collection=docs"), "concepts/api.mdx": () => import("../content/docs/concepts/api.mdx?collection=docs"), "concepts/client.mdx": () => import("../content/docs/concepts/client.mdx?collection=docs"), "concepts/customers.mdx": () => import("../content/docs/concepts/customers.mdx?collection=docs"), "concepts/hooks.mdx": () => import("../content/docs/concepts/hooks.mdx?collection=docs"), "concepts/payment-providers.mdx": () => import("../content/docs/concepts/payment-providers.mdx?collection=docs"), "concepts/pix.mdx": () => import("../content/docs/concepts/pix.mdx?collection=docs"), "concepts/sandbox.mdx": () => import("../content/docs/concepts/sandbox.mdx?collection=docs"), "concepts/typescript.mdx": () => import("../content/docs/concepts/typescript.mdx?collection=docs"), "database/drizzle.mdx": () => import("../content/docs/database/drizzle.mdx?collection=docs"), "database/memory.mdx": () => import("../content/docs/database/memory.mdx?collection=docs"), "database/overview.mdx": () => import("../content/docs/database/overview.mdx?collection=docs"), "database/postgres.mdx": () => import("../content/docs/database/postgres.mdx?collection=docs"), "database/prisma.mdx": () => import("../content/docs/database/prisma.mdx?collection=docs"), "guides/create-a-database-adapter.mdx": () => import("../content/docs/guides/create-a-database-adapter.mdx?collection=docs"), "guides/create-a-provider.mdx": () => import("../content/docs/guides/create-a-provider.mdx?collection=docs"), "guides/create-your-first-plugin.mdx": () => import("../content/docs/guides/create-your-first-plugin.mdx?collection=docs"), "guides/database.mdx": () => import("../content/docs/guides/database.mdx?collection=docs"), "guides/optimize-performance.mdx": () => import("../content/docs/guides/optimize-performance.mdx?collection=docs"), "guides/plugins.mdx": () => import("../content/docs/guides/plugins.mdx?collection=docs"), "guides/provider-selection.mdx": () => import("../content/docs/guides/provider-selection.mdx?collection=docs"), "guides/testing.mdx": () => import("../content/docs/guides/testing.mdx?collection=docs"), "guides/webhooks-at-scale.mdx": () => import("../content/docs/guides/webhooks-at-scale.mdx?collection=docs"), "guides/webhooks.mdx": () => import("../content/docs/guides/webhooks.mdx?collection=docs"), "providers/abacatepay.mdx": () => import("../content/docs/providers/abacatepay.mdx?collection=docs"), "providers/dodo.mdx": () => import("../content/docs/providers/dodo.mdx?collection=docs"), "providers/polar.mdx": () => import("../content/docs/providers/polar.mdx?collection=docs"), "providers/stripe.mdx": () => import("../content/docs/providers/stripe.mdx?collection=docs"), "plugins/audit-logs.mdx": () => import("../content/docs/plugins/audit-logs.mdx?collection=docs"), "plugins/dash.mdx": () => import("../content/docs/plugins/dash.mdx?collection=docs"), "plugins/overview.mdx": () => import("../content/docs/plugins/overview.mdx?collection=docs"), "reference/cli.mdx": () => import("../content/docs/reference/cli.mdx?collection=docs"), "reference/client-options.mdx": () => import("../content/docs/reference/client-options.mdx?collection=docs"), "reference/database-schema.mdx": () => import("../content/docs/reference/database-schema.mdx?collection=docs"), "reference/errors.mdx": () => import("../content/docs/reference/errors.mdx?collection=docs"), "reference/plugin-api.mdx": () => import("../content/docs/reference/plugin-api.mdx?collection=docs"), }), }; export default browserCollections; \ No newline at end of file diff --git a/apps/web/.source/server.ts b/apps/web/.source/server.ts index 481ba5c..a5e160a 100644 --- a/apps/web/.source/server.ts +++ b/apps/web/.source/server.ts @@ -1,37 +1,39 @@ // @ts-nocheck -import { frontmatter as __fd_glob_43 } from "../content/docs/reference/plugin-api.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_42 } from "../content/docs/reference/errors.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_41 } from "../content/docs/reference/database-schema.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_40 } from "../content/docs/reference/client-options.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_39 } from "../content/docs/reference/cli.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_38 } from "../content/docs/providers/stripe.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_37 } from "../content/docs/providers/polar.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_36 } from "../content/docs/providers/abacatepay.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_35 } from "../content/docs/plugins/overview.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_34 } from "../content/docs/plugins/dash.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_33 } from "../content/docs/plugins/audit-logs.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_32 } from "../content/docs/guides/webhooks.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_31 } from "../content/docs/guides/webhooks-at-scale.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_30 } from "../content/docs/guides/testing.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_29 } from "../content/docs/guides/provider-selection.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_28 } from "../content/docs/guides/plugins.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_27 } from "../content/docs/guides/optimize-performance.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_26 } from "../content/docs/guides/database.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_25 } from "../content/docs/guides/create-your-first-plugin.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_24 } from "../content/docs/guides/create-a-provider.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_23 } from "../content/docs/guides/create-a-database-adapter.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_22 } from "../content/docs/concepts/typescript.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_21 } from "../content/docs/concepts/sandbox.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_20 } from "../content/docs/concepts/pix.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_19 } from "../content/docs/concepts/payment-providers.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_18 } from "../content/docs/concepts/hooks.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_17 } from "../content/docs/concepts/customers.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_16 } from "../content/docs/concepts/client.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_15 } from "../content/docs/concepts/api.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_14 } from "../content/docs/database/prisma.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_13 } from "../content/docs/database/postgres.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_12 } from "../content/docs/database/overview.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_11 } from "../content/docs/database/drizzle.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_45 } from "../content/docs/reference/plugin-api.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_44 } from "../content/docs/reference/errors.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_43 } from "../content/docs/reference/database-schema.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_42 } from "../content/docs/reference/client-options.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_41 } from "../content/docs/reference/cli.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_40 } from "../content/docs/plugins/overview.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_39 } from "../content/docs/plugins/dash.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_38 } from "../content/docs/plugins/audit-logs.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_37 } from "../content/docs/providers/stripe.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_36 } from "../content/docs/providers/polar.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_35 } from "../content/docs/providers/dodo.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_34 } from "../content/docs/providers/abacatepay.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_33 } from "../content/docs/guides/webhooks.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_32 } from "../content/docs/guides/webhooks-at-scale.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_31 } from "../content/docs/guides/testing.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_30 } from "../content/docs/guides/provider-selection.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_29 } from "../content/docs/guides/plugins.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_28 } from "../content/docs/guides/optimize-performance.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_27 } from "../content/docs/guides/database.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_26 } from "../content/docs/guides/create-your-first-plugin.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_25 } from "../content/docs/guides/create-a-provider.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_24 } from "../content/docs/guides/create-a-database-adapter.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_23 } from "../content/docs/database/prisma.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_22 } from "../content/docs/database/postgres.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_21 } from "../content/docs/database/overview.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_20 } from "../content/docs/database/memory.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_19 } from "../content/docs/database/drizzle.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_18 } from "../content/docs/concepts/typescript.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_17 } from "../content/docs/concepts/sandbox.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_16 } from "../content/docs/concepts/pix.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_15 } from "../content/docs/concepts/payment-providers.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_14 } from "../content/docs/concepts/hooks.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_13 } from "../content/docs/concepts/customers.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_12 } from "../content/docs/concepts/client.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_11 } from "../content/docs/concepts/api.mdx?collection=docs&only=frontmatter" import { frontmatter as __fd_glob_10 } from "../content/docs/ai-resources/skills.mdx?collection=docs&only=frontmatter" import { frontmatter as __fd_glob_9 } from "../content/docs/adapters/next.mdx?collection=docs&only=frontmatter" import { frontmatter as __fd_glob_8 } from "../content/docs/adapters/hono.mdx?collection=docs&only=frontmatter" @@ -51,4 +53,4 @@ const create = server({"doc":{"passthroughs":["extractedReferences"]}}); -export const docs = await create.docsLazy("docs", "content/docs", {}, {"basic-usage.mdx": __fd_glob_0, "comparison.mdx": __fd_glob_1, "installation.mdx": __fd_glob_2, "introduction.mdx": __fd_glob_3, "mcp.mdx": __fd_glob_4, "adapters/elysia.mdx": __fd_glob_5, "adapters/express.mdx": __fd_glob_6, "adapters/fastify.mdx": __fd_glob_7, "adapters/hono.mdx": __fd_glob_8, "adapters/next.mdx": __fd_glob_9, "ai-resources/skills.mdx": __fd_glob_10, "database/drizzle.mdx": __fd_glob_11, "database/overview.mdx": __fd_glob_12, "database/postgres.mdx": __fd_glob_13, "database/prisma.mdx": __fd_glob_14, "concepts/api.mdx": __fd_glob_15, "concepts/client.mdx": __fd_glob_16, "concepts/customers.mdx": __fd_glob_17, "concepts/hooks.mdx": __fd_glob_18, "concepts/payment-providers.mdx": __fd_glob_19, "concepts/pix.mdx": __fd_glob_20, "concepts/sandbox.mdx": __fd_glob_21, "concepts/typescript.mdx": __fd_glob_22, "guides/create-a-database-adapter.mdx": __fd_glob_23, "guides/create-a-provider.mdx": __fd_glob_24, "guides/create-your-first-plugin.mdx": __fd_glob_25, "guides/database.mdx": __fd_glob_26, "guides/optimize-performance.mdx": __fd_glob_27, "guides/plugins.mdx": __fd_glob_28, "guides/provider-selection.mdx": __fd_glob_29, "guides/testing.mdx": __fd_glob_30, "guides/webhooks-at-scale.mdx": __fd_glob_31, "guides/webhooks.mdx": __fd_glob_32, "plugins/audit-logs.mdx": __fd_glob_33, "plugins/dash.mdx": __fd_glob_34, "plugins/overview.mdx": __fd_glob_35, "providers/abacatepay.mdx": __fd_glob_36, "providers/polar.mdx": __fd_glob_37, "providers/stripe.mdx": __fd_glob_38, "reference/cli.mdx": __fd_glob_39, "reference/client-options.mdx": __fd_glob_40, "reference/database-schema.mdx": __fd_glob_41, "reference/errors.mdx": __fd_glob_42, "reference/plugin-api.mdx": __fd_glob_43, }, {"basic-usage.mdx": () => import("../content/docs/basic-usage.mdx?collection=docs"), "comparison.mdx": () => import("../content/docs/comparison.mdx?collection=docs"), "installation.mdx": () => import("../content/docs/installation.mdx?collection=docs"), "introduction.mdx": () => import("../content/docs/introduction.mdx?collection=docs"), "mcp.mdx": () => import("../content/docs/mcp.mdx?collection=docs"), "adapters/elysia.mdx": () => import("../content/docs/adapters/elysia.mdx?collection=docs"), "adapters/express.mdx": () => import("../content/docs/adapters/express.mdx?collection=docs"), "adapters/fastify.mdx": () => import("../content/docs/adapters/fastify.mdx?collection=docs"), "adapters/hono.mdx": () => import("../content/docs/adapters/hono.mdx?collection=docs"), "adapters/next.mdx": () => import("../content/docs/adapters/next.mdx?collection=docs"), "ai-resources/skills.mdx": () => import("../content/docs/ai-resources/skills.mdx?collection=docs"), "database/drizzle.mdx": () => import("../content/docs/database/drizzle.mdx?collection=docs"), "database/overview.mdx": () => import("../content/docs/database/overview.mdx?collection=docs"), "database/postgres.mdx": () => import("../content/docs/database/postgres.mdx?collection=docs"), "database/prisma.mdx": () => import("../content/docs/database/prisma.mdx?collection=docs"), "concepts/api.mdx": () => import("../content/docs/concepts/api.mdx?collection=docs"), "concepts/client.mdx": () => import("../content/docs/concepts/client.mdx?collection=docs"), "concepts/customers.mdx": () => import("../content/docs/concepts/customers.mdx?collection=docs"), "concepts/hooks.mdx": () => import("../content/docs/concepts/hooks.mdx?collection=docs"), "concepts/payment-providers.mdx": () => import("../content/docs/concepts/payment-providers.mdx?collection=docs"), "concepts/pix.mdx": () => import("../content/docs/concepts/pix.mdx?collection=docs"), "concepts/sandbox.mdx": () => import("../content/docs/concepts/sandbox.mdx?collection=docs"), "concepts/typescript.mdx": () => import("../content/docs/concepts/typescript.mdx?collection=docs"), "guides/create-a-database-adapter.mdx": () => import("../content/docs/guides/create-a-database-adapter.mdx?collection=docs"), "guides/create-a-provider.mdx": () => import("../content/docs/guides/create-a-provider.mdx?collection=docs"), "guides/create-your-first-plugin.mdx": () => import("../content/docs/guides/create-your-first-plugin.mdx?collection=docs"), "guides/database.mdx": () => import("../content/docs/guides/database.mdx?collection=docs"), "guides/optimize-performance.mdx": () => import("../content/docs/guides/optimize-performance.mdx?collection=docs"), "guides/plugins.mdx": () => import("../content/docs/guides/plugins.mdx?collection=docs"), "guides/provider-selection.mdx": () => import("../content/docs/guides/provider-selection.mdx?collection=docs"), "guides/testing.mdx": () => import("../content/docs/guides/testing.mdx?collection=docs"), "guides/webhooks-at-scale.mdx": () => import("../content/docs/guides/webhooks-at-scale.mdx?collection=docs"), "guides/webhooks.mdx": () => import("../content/docs/guides/webhooks.mdx?collection=docs"), "plugins/audit-logs.mdx": () => import("../content/docs/plugins/audit-logs.mdx?collection=docs"), "plugins/dash.mdx": () => import("../content/docs/plugins/dash.mdx?collection=docs"), "plugins/overview.mdx": () => import("../content/docs/plugins/overview.mdx?collection=docs"), "providers/abacatepay.mdx": () => import("../content/docs/providers/abacatepay.mdx?collection=docs"), "providers/polar.mdx": () => import("../content/docs/providers/polar.mdx?collection=docs"), "providers/stripe.mdx": () => import("../content/docs/providers/stripe.mdx?collection=docs"), "reference/cli.mdx": () => import("../content/docs/reference/cli.mdx?collection=docs"), "reference/client-options.mdx": () => import("../content/docs/reference/client-options.mdx?collection=docs"), "reference/database-schema.mdx": () => import("../content/docs/reference/database-schema.mdx?collection=docs"), "reference/errors.mdx": () => import("../content/docs/reference/errors.mdx?collection=docs"), "reference/plugin-api.mdx": () => import("../content/docs/reference/plugin-api.mdx?collection=docs"), }); \ No newline at end of file +export const docs = await create.docsLazy("docs", "content/docs", {}, {"basic-usage.mdx": __fd_glob_0, "comparison.mdx": __fd_glob_1, "installation.mdx": __fd_glob_2, "introduction.mdx": __fd_glob_3, "mcp.mdx": __fd_glob_4, "adapters/elysia.mdx": __fd_glob_5, "adapters/express.mdx": __fd_glob_6, "adapters/fastify.mdx": __fd_glob_7, "adapters/hono.mdx": __fd_glob_8, "adapters/next.mdx": __fd_glob_9, "ai-resources/skills.mdx": __fd_glob_10, "concepts/api.mdx": __fd_glob_11, "concepts/client.mdx": __fd_glob_12, "concepts/customers.mdx": __fd_glob_13, "concepts/hooks.mdx": __fd_glob_14, "concepts/payment-providers.mdx": __fd_glob_15, "concepts/pix.mdx": __fd_glob_16, "concepts/sandbox.mdx": __fd_glob_17, "concepts/typescript.mdx": __fd_glob_18, "database/drizzle.mdx": __fd_glob_19, "database/memory.mdx": __fd_glob_20, "database/overview.mdx": __fd_glob_21, "database/postgres.mdx": __fd_glob_22, "database/prisma.mdx": __fd_glob_23, "guides/create-a-database-adapter.mdx": __fd_glob_24, "guides/create-a-provider.mdx": __fd_glob_25, "guides/create-your-first-plugin.mdx": __fd_glob_26, "guides/database.mdx": __fd_glob_27, "guides/optimize-performance.mdx": __fd_glob_28, "guides/plugins.mdx": __fd_glob_29, "guides/provider-selection.mdx": __fd_glob_30, "guides/testing.mdx": __fd_glob_31, "guides/webhooks-at-scale.mdx": __fd_glob_32, "guides/webhooks.mdx": __fd_glob_33, "providers/abacatepay.mdx": __fd_glob_34, "providers/dodo.mdx": __fd_glob_35, "providers/polar.mdx": __fd_glob_36, "providers/stripe.mdx": __fd_glob_37, "plugins/audit-logs.mdx": __fd_glob_38, "plugins/dash.mdx": __fd_glob_39, "plugins/overview.mdx": __fd_glob_40, "reference/cli.mdx": __fd_glob_41, "reference/client-options.mdx": __fd_glob_42, "reference/database-schema.mdx": __fd_glob_43, "reference/errors.mdx": __fd_glob_44, "reference/plugin-api.mdx": __fd_glob_45, }, {"basic-usage.mdx": () => import("../content/docs/basic-usage.mdx?collection=docs"), "comparison.mdx": () => import("../content/docs/comparison.mdx?collection=docs"), "installation.mdx": () => import("../content/docs/installation.mdx?collection=docs"), "introduction.mdx": () => import("../content/docs/introduction.mdx?collection=docs"), "mcp.mdx": () => import("../content/docs/mcp.mdx?collection=docs"), "adapters/elysia.mdx": () => import("../content/docs/adapters/elysia.mdx?collection=docs"), "adapters/express.mdx": () => import("../content/docs/adapters/express.mdx?collection=docs"), "adapters/fastify.mdx": () => import("../content/docs/adapters/fastify.mdx?collection=docs"), "adapters/hono.mdx": () => import("../content/docs/adapters/hono.mdx?collection=docs"), "adapters/next.mdx": () => import("../content/docs/adapters/next.mdx?collection=docs"), "ai-resources/skills.mdx": () => import("../content/docs/ai-resources/skills.mdx?collection=docs"), "concepts/api.mdx": () => import("../content/docs/concepts/api.mdx?collection=docs"), "concepts/client.mdx": () => import("../content/docs/concepts/client.mdx?collection=docs"), "concepts/customers.mdx": () => import("../content/docs/concepts/customers.mdx?collection=docs"), "concepts/hooks.mdx": () => import("../content/docs/concepts/hooks.mdx?collection=docs"), "concepts/payment-providers.mdx": () => import("../content/docs/concepts/payment-providers.mdx?collection=docs"), "concepts/pix.mdx": () => import("../content/docs/concepts/pix.mdx?collection=docs"), "concepts/sandbox.mdx": () => import("../content/docs/concepts/sandbox.mdx?collection=docs"), "concepts/typescript.mdx": () => import("../content/docs/concepts/typescript.mdx?collection=docs"), "database/drizzle.mdx": () => import("../content/docs/database/drizzle.mdx?collection=docs"), "database/memory.mdx": () => import("../content/docs/database/memory.mdx?collection=docs"), "database/overview.mdx": () => import("../content/docs/database/overview.mdx?collection=docs"), "database/postgres.mdx": () => import("../content/docs/database/postgres.mdx?collection=docs"), "database/prisma.mdx": () => import("../content/docs/database/prisma.mdx?collection=docs"), "guides/create-a-database-adapter.mdx": () => import("../content/docs/guides/create-a-database-adapter.mdx?collection=docs"), "guides/create-a-provider.mdx": () => import("../content/docs/guides/create-a-provider.mdx?collection=docs"), "guides/create-your-first-plugin.mdx": () => import("../content/docs/guides/create-your-first-plugin.mdx?collection=docs"), "guides/database.mdx": () => import("../content/docs/guides/database.mdx?collection=docs"), "guides/optimize-performance.mdx": () => import("../content/docs/guides/optimize-performance.mdx?collection=docs"), "guides/plugins.mdx": () => import("../content/docs/guides/plugins.mdx?collection=docs"), "guides/provider-selection.mdx": () => import("../content/docs/guides/provider-selection.mdx?collection=docs"), "guides/testing.mdx": () => import("../content/docs/guides/testing.mdx?collection=docs"), "guides/webhooks-at-scale.mdx": () => import("../content/docs/guides/webhooks-at-scale.mdx?collection=docs"), "guides/webhooks.mdx": () => import("../content/docs/guides/webhooks.mdx?collection=docs"), "providers/abacatepay.mdx": () => import("../content/docs/providers/abacatepay.mdx?collection=docs"), "providers/dodo.mdx": () => import("../content/docs/providers/dodo.mdx?collection=docs"), "providers/polar.mdx": () => import("../content/docs/providers/polar.mdx?collection=docs"), "providers/stripe.mdx": () => import("../content/docs/providers/stripe.mdx?collection=docs"), "plugins/audit-logs.mdx": () => import("../content/docs/plugins/audit-logs.mdx?collection=docs"), "plugins/dash.mdx": () => import("../content/docs/plugins/dash.mdx?collection=docs"), "plugins/overview.mdx": () => import("../content/docs/plugins/overview.mdx?collection=docs"), "reference/cli.mdx": () => import("../content/docs/reference/cli.mdx?collection=docs"), "reference/client-options.mdx": () => import("../content/docs/reference/client-options.mdx?collection=docs"), "reference/database-schema.mdx": () => import("../content/docs/reference/database-schema.mdx?collection=docs"), "reference/errors.mdx": () => import("../content/docs/reference/errors.mdx?collection=docs"), "reference/plugin-api.mdx": () => import("../content/docs/reference/plugin-api.mdx?collection=docs"), }); \ No newline at end of file diff --git a/apps/web/content/docs/basic-usage.mdx b/apps/web/content/docs/basic-usage.mdx index e5ede22..3803aca 100644 --- a/apps/web/content/docs/basic-usage.mdx +++ b/apps/web/content/docs/basic-usage.mdx @@ -113,7 +113,7 @@ console.log(pix.expiresAt); ``` - In the current repository, native `pix` capability is implemented by `@paymesh/stripe` and `@paymesh/abacatepay`. The Polar provider intentionally does not advertise PIX support. + In the current repository, native `pix` capability is implemented by `@paymesh/stripe` and `@paymesh/abacatepay`. `@paymesh/polar` and `@paymesh/dodo` intentionally advertise `pix: false`. Dodo can still show Pix inside BRL hosted checkout links created through `paymesh.payments.create()`. ## Upsert and read a customer diff --git a/apps/web/content/docs/concepts/payment-providers.mdx b/apps/web/content/docs/concepts/payment-providers.mdx index 2a7591a..1727cec 100644 --- a/apps/web/content/docs/concepts/payment-providers.mdx +++ b/apps/web/content/docs/concepts/payment-providers.mdx @@ -54,6 +54,18 @@ AbacatePay currently implements: AbacatePay does **not** expose subscriptions, refunds, or customer portal in the current repository. +### Dodo + +Dodo currently implements: + +- hosted checkout payments through Dodo product carts +- customers +- catalog reads +- subscription-aware dashboard sync +- webhook verification and event mapping + +Dodo does **not** expose `paymesh.pix` in the current repository. BRL hosted checkouts can still present Pix inside the Dodo checkout page, but the provider does not currently return a native QR-code-first PIX object. + ## Capability flags Provider differences are carried through capability flags rather than hidden. @@ -72,6 +84,7 @@ Some provider-specific differences still matter. For example: - Polar requires `productIds` for checkout creation - AbacatePay requires `productIds` for checkout creation and uses catalog-driven checkout +- Dodo requires `productIds` for payment creation and can enable hosted Pix inside BRL checkouts - Stripe PIX supports `amountIncludesIof` and expiration settings - dashboard integrations vary by provider diff --git a/apps/web/content/docs/concepts/pix.mdx b/apps/web/content/docs/concepts/pix.mdx index 8072d0a..3c1cac5 100644 --- a/apps/web/content/docs/concepts/pix.mdx +++ b/apps/web/content/docs/concepts/pix.mdx @@ -64,12 +64,17 @@ In the current repository: - `@paymesh/stripe` advertises `pix: true` - `@paymesh/polar` advertises `pix: false` - `@paymesh/abacatepay` advertises `pix: true` +- `@paymesh/dodo` advertises `pix: false` That means PIX-aware product code should either: - choose a provider that exposes PIX - or gate PIX UI behind provider capability detection + + `@paymesh/dodo` can still surface Pix as a hosted payment method for BRL checkout links created through `paymesh.payments.create()`. It is not exposed through `paymesh.pix` because Dodo does not currently document a backend-native PIX flow that matches Paymesh's QR-code-first contract. + + ## Persistence model With a database adapter attached, PIX state is stored separately in the built-in `pix` table. This is important because PIX-specific fields do not fit cleanly inside generic checkout rows. diff --git a/apps/web/content/docs/installation.mdx b/apps/web/content/docs/installation.mdx index 62a8805..4273b72 100644 --- a/apps/web/content/docs/installation.mdx +++ b/apps/web/content/docs/installation.mdx @@ -19,6 +19,10 @@ description: Install Paymesh, wire a provider, optionally add persistence, and m + If you need Dodo's catalog-driven hosted checkout flow: + + + You do not need a database on day one, but most real installations should add one quickly so webhook state, customers, PIX objects, and catalog snapshots are queryable locally. @@ -29,7 +33,7 @@ description: Install Paymesh, wire a provider, optionally add persistence, and m Put the client in a stable server-side module such as `src/lib/paymesh.ts`, `src/server/paymesh.ts`, or `app/lib/paymesh.ts`. - + ```ts title="src/lib/paymesh.ts" import { createClient } from "paymesh"; @@ -69,7 +73,25 @@ description: Install Paymesh, wire a provider, optionally add persistence, and m }); ``` + + ```ts title="src/lib/paymesh.ts" + import { createClient } from "paymesh"; + import { dodo } from "@paymesh/dodo"; + + export const paymesh = createClient({ + provider: dodo({ + apiKey: process.env.DODO_PAYMENTS_API_KEY!, + webhookSecret: process.env.DODO_PAYMENTS_WEBHOOK_KEY, + baseUrl: "https://test.dodopayments.com", + }), + }); + ``` + + + + `@paymesh/dodo` is catalog-driven like Polar and AbacatePay checkout flows. You create hosted payments with `productIds`. For BRL payments, the Dodo hosted checkout can offer Pix, but the provider does not expose `paymesh.pix`. + diff --git a/apps/web/content/docs/introduction.mdx b/apps/web/content/docs/introduction.mdx index 94f54d2..b286bdb 100644 --- a/apps/web/content/docs/introduction.mdx +++ b/apps/web/content/docs/introduction.mdx @@ -25,7 +25,7 @@ Paymesh exists to normalize those concerns. At runtime, Paymesh is built from four layers: 1. A `client` created with `createClient`. -2. A `provider` such as `@paymesh/stripe` or `@paymesh/polar`. +2. A `provider` such as `@paymesh/stripe`, `@paymesh/polar`, or `@paymesh/dodo`. 3. An optional `database` adapter such as `@paymesh/memory`, `@paymesh/postgres`, `@paymesh/drizzle`, or `@paymesh/prisma`. 4. Optional `plugins` such as `@paymesh/dash` and `@paymesh/audit-logs`. @@ -75,14 +75,14 @@ The current repository exposes these first-party modules: | Area | Package | | --- | --- | | Core client | `paymesh` | -| Providers | `@paymesh/stripe`, `@paymesh/polar`, `@paymesh/abacatepay` | +| Providers | `@paymesh/stripe`, `@paymesh/polar`, `@paymesh/abacatepay`, `@paymesh/dodo` | | Database adapters | `@paymesh/memory`, `@paymesh/postgres`, `@paymesh/drizzle`, `@paymesh/prisma` | | Framework adapters | `@paymesh/next`, `@paymesh/express`, `@paymesh/fastify`, `@paymesh/hono`, `@paymesh/elysia` | | Plugins | `@paymesh/dash`, `@paymesh/audit-logs` | | Tooling | `@paymesh/cli` | - The docs below stay anchored to what is actually exported in the repository. Planned providers like PayPal and Dodo are treated as planned work, not presented like shipped integrations. + The docs below stay anchored to what is actually exported in the repository. PayPal remains planned work. Dodo is shipped as a catalog-driven hosted checkout provider with customer, subscription, catalog, and webhook support. ## Read this documentation like a system diff --git a/apps/web/content/docs/providers/dodo.mdx b/apps/web/content/docs/providers/dodo.mdx new file mode 100644 index 0000000..b620e19 --- /dev/null +++ b/apps/web/content/docs/providers/dodo.mdx @@ -0,0 +1,203 @@ +--- +title: Dodo +description: "Detailed guide to the Dodo provider: setup options, catalog-driven hosted payments, customers, catalog reads, dashboard sync helpers, and webhook normalization." +--- + +## Overview + +`@paymesh/dodo` is the first-party Paymesh provider for Dodo Payments. It is the right choice when you want: + +- catalog-driven hosted checkout payments +- normalized customer lifecycle methods +- catalog reads from Dodo products +- subscription-aware webhook mapping +- dashboard sync helpers for customer, payment, and subscription state + +Unlike Stripe and AbacatePay, Dodo in this repository does **not** expose `paymesh.pix`. For BRL checkouts, the Dodo hosted checkout can still offer Pix inside the redirect flow. + +## Installation + + + +## Provider options + +| Option | Required | Description | +| --- | --- | --- | +| `apiKey` | yes in practice | Dodo API key used for authenticated requests. Defaults to `process.env.DODO_PAYMENTS_API_KEY`. | +| `webhookSecret` | recommended | Secret used to verify `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers. Defaults to `process.env.DODO_PAYMENTS_WEBHOOK_KEY`. | +| `sandbox` | no | Forces sandbox mode. Auto-detected from the base URL when omitted. | +| `baseUrl` | no | Overrides the Dodo API base URL. Defaults to `https://live.dodopayments.com`. Use `https://test.dodopayments.com` for sandbox. | +| `retry` | no | Retry policy forwarded to the shared request layer. | +| `timeout` | no | Per-request timeout in milliseconds. | +| `fetch` | no | Custom fetch implementation for tests or runtime-specific environments. | + +```ts title="src/lib/paymesh.ts" +import { createClient } from "paymesh"; +import { dodo } from "@paymesh/dodo"; + +export const paymesh = createClient({ + provider: dodo({ + apiKey: process.env.DODO_PAYMENTS_API_KEY!, + webhookSecret: process.env.DODO_PAYMENTS_WEBHOOK_KEY, + baseUrl: "https://test.dodopayments.com", + }), +}); +``` + +## Capability profile + +| Capability | Supported | +| --- | --- | +| `checkout` | yes | +| `pix` | no | +| `customers` | yes | +| `subscriptions` | yes | +| `webhooks` | yes | +| `refunds` | no | +| `customerPortal` | no | +| `coupons` | no | + +## Hosted checkout payments + +Dodo payment creation is catalog-driven. You must pass `productIds`. + +```ts title="src/server/payments.ts" +const payment = await paymesh.payments.create({ + amount: 4900, + currency: "BRL", + productIds: ["prod_abc123"], + customer: { + email: "billing@example.com", + externalId: "order_42", + name: "Billing Team", + }, + successUrl: "http://localhost:3000/success", + cancelUrl: "http://localhost:3000/cancel", + metadata: { + orderId: "order_42", + }, +}); +``` + +### Important constraints + +- `productIds` is required +- `customer.id` or `customer.email` is required +- `amount` can only be passed when exactly one product id is provided + +### Request mapping details + +- `productIds` maps to `product_cart` +- `amount` maps into the product cart entry when one product id is present +- `currency` maps to `billing_currency` +- `customer.id` maps to `customer.customer_id` +- `customer.email`, `customer.name`, and `customer.phone` map into the embedded customer payload when no `customer.id` exists +- `metadata` values are serialized to strings before sending to Dodo +- `successUrl`, `cancelUrl`, and `returnUrl` resolve into one `return_url` + +For BRL checkouts, the provider also enables Dodo's hosted Pix-capable payment method set automatically. + +## PIX note + +`@paymesh/dodo` does not implement `paymesh.pix`. + +That is deliberate. Dodo can present Pix inside the hosted checkout page for BRL payments, but the current Dodo API documentation does not describe a backend-native PIX flow that returns Paymesh's QR-code-first PIX fields such as `copyPasteCode` and `qrCodeImageUrlPng`. + +If your product needs native PIX payload fields, choose `@paymesh/stripe` or `@paymesh/abacatepay` instead. + +## Customer operations + +Dodo customer methods are normalized through `paymesh.customers`. + +```ts title="src/server/customers.ts" +const customer = await paymesh.customers.upsert({ + email: "billing@example.com", + externalId: "org_42", + name: "ACME Corp", + phone: "+55 11 99999-9999", + metadata: { + segment: "enterprise", + }, +}); +``` + +### Customer mapping notes + +- `externalId` is stored in Dodo metadata as `externalId` +- create uses `POST /customers` +- update uses `PATCH /customers/:id` +- delete is intentionally unsupported by this provider package + +If you attempt to create a customer without `email`, the provider throws `invalid_request`. + +## Catalog reads + +Dodo exposes `provider.catalog.list()` and reads products from `/products`. + +Paymesh normalizes: + +- products +- one synthetic price entry per product when price data exists +- recurring interval metadata when Dodo exposes recurring pricing fields + +```ts title="src/server/catalog.ts" +const catalog = await paymesh.provider.catalog?.list(); + +console.log(catalog?.products.length, catalog?.prices.length); +``` + +## Dashboard helpers + +Dodo ships `provider.dashboard` with: + +- customer sync +- payment sync +- subscription sync + +`getResourceUrl()` currently returns `null`, so operational dashboards should not assume Dodo deep links are available. + +## Webhook verification + +The provider verifies Dodo webhooks using: + +- `webhook-id` +- `webhook-timestamp` +- `webhook-signature` + +It rebuilds the signed message in the standard Dodo format and checks the `v1` HMAC-SHA256 signature against the configured webhook secret. + +If `webhookSecret` is missing, verification returns `false`. + +## Webhook normalization + +The provider currently normalizes these Dodo event families: + +| Dodo event | Paymesh event | +| --- | --- | +| `payment.succeeded` | `payment.succeeded` | +| `payment.failed` | `payment.failed` | +| `payment.processing` | `payment.created` | +| `payment.cancelled` | `payment.canceled` | +| `refund.succeeded` | `payment.refunded` | +| `refund.failed` | `payment.failed` | +| `subscription.active` | `subscription.created` | +| `subscription.renewed` | `subscription.updated` | +| `subscription.on_hold` | `subscription.updated` | +| `subscription.paused` | `subscription.updated` | +| `subscription.cancelled` | `subscription.canceled` | +| `subscription.failed` | `subscription.updated` | +| `subscription.expired` | `subscription.canceled` | +| `subscription.plan_changed` | `subscription.updated` | +| `subscription.updated` | `subscription.updated` | + +Pix-shaped Dodo payment payloads are still normalized as payments with `method: "pix"` when the webhook includes Pix-specific fields. + +## Choosing Dodo + +Use Dodo when: + +- your checkout flow is catalog-driven +- you want Dodo Payments as the upstream billing system +- you need hosted BRL checkout links that may offer Pix without building a native PIX flow + +Do not choose Dodo when your product requires `paymesh.pix` with QR code, copia-e-cola, or explicit PIX expiration fields as first-class backend data. diff --git a/apps/web/public/llms.txt b/apps/web/public/llms.txt index fb05132..6e07f04 100644 --- a/apps/web/public/llms.txt +++ b/apps/web/public/llms.txt @@ -9,7 +9,7 @@ For canonical documentation, prefer the docs pages linked below over inferring b ## Canonical project facts - Core package: `paymesh` -- Official live providers: `@paymesh/stripe`, `@paymesh/polar`, `@paymesh/abacatepay` +- Official live providers: `@paymesh/stripe`, `@paymesh/polar`, `@paymesh/abacatepay`, `@paymesh/dodo` - Official webhook adapters: `@paymesh/next`, `@paymesh/express`, `@paymesh/fastify`, `@paymesh/hono`, `@paymesh/elysia` - Official database adapters: `@paymesh/postgres`, `@paymesh/drizzle`, `@paymesh/prisma` - Official plugins: `@paymesh/dash`, `@paymesh/audit-logs` @@ -166,12 +166,30 @@ Important limitation: - `@paymesh/abacatepay` does not currently support subscriptions, refunds, or customer portal +### Dodo + +Package: `@paymesh/dodo` + +Implemented capabilities: + +- checkout payments (catalog-driven with product IDs) +- customers +- webhooks +- subscriptions +- catalog reads +- dashboard sync helpers for customers, payments, and subscriptions + +Important limitations: + +- `@paymesh/dodo` currently advertises `pix: false` +- Dodo can expose Pix inside BRL hosted checkout, but not through `paymesh.pix` +- customer delete is intentionally unsupported + ### Planned providers These appear in docs/navigation as planned work and should not be described as shipped integrations: - PayPal -- Dodo ## PIX @@ -184,7 +202,7 @@ PIX is a first-class concept in Paymesh core, with normalized fields such as: - `instructionsUrl` - `expiresAt` -PIX support is provider-specific. Stripe supports PIX. Polar currently does not. AbacatePay supports PIX through transparent charges. +PIX support is provider-specific. Stripe supports PIX. Polar currently does not. AbacatePay supports PIX through transparent charges. Dodo can show hosted Pix for BRL checkouts but does not expose native `paymesh.pix`. PIX create input supports: @@ -475,7 +493,7 @@ Supported commands: - Prefer the docs pages above when answering questions about Paymesh. - Do not describe planned providers as implemented. -- Treat Stripe, Polar, and AbacatePay as the official live providers. +- Treat Stripe, Polar, AbacatePay, and Dodo as the official live providers. - Treat PIX support as provider-specific, not universal. - Mention raw-body requirements when discussing Express or Fastify webhooks. - Mention that plugins are real runtime modules, not just helper functions. diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index e2386d7..d6660e5 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -127,12 +127,12 @@ const providerCards: ProviderCard[] = [ { id: '05', name: 'Dodo Payments', - status: 'coming soon', - headline: 'Dodo is queued.', + status: 'supported', + headline: 'Dodo is live.', description: - 'Internet-native billing for teams that want optionality without integration churn.', + 'Catalog-driven hosted payments, customers, subscriptions, and webhook normalization through the same Paymesh client.', icon: 'dodo', - tags: ['global', 'merchant', 'tax'], + tags: ['catalog', 'hosted', 'webhooks'], }, { id: '06', diff --git a/apps/web/src/components/docs/mdx-components.tsx b/apps/web/src/components/docs/mdx-components.tsx index 585f993..344fdb7 100644 --- a/apps/web/src/components/docs/mdx-components.tsx +++ b/apps/web/src/components/docs/mdx-components.tsx @@ -12,6 +12,7 @@ const packageDocsLinks: Record = { paymesh: '/docs/introduction', '@paymesh/stripe': '/docs/providers/stripe', '@paymesh/polar': '/docs/providers/polar', + '@paymesh/dodo': '/docs/providers/dodo', '@paymesh/next': '/docs/adapters/next', '@paymesh/express': '/docs/adapters/express', '@paymesh/fastify': '/docs/adapters/fastify', diff --git a/apps/web/src/lib/docs-navigation.ts b/apps/web/src/lib/docs-navigation.ts index f0d8a05..f793647 100644 --- a/apps/web/src/lib/docs-navigation.ts +++ b/apps/web/src/lib/docs-navigation.ts @@ -114,8 +114,8 @@ export const docsNavigation: DocNavGroup[] = [ }, { label: 'Dodo', - description: 'Planned provider', - status: 'planned', + href: '/docs/providers/dodo', + description: 'Catalog-driven Dodo Payments provider.', icon: 'dodo', }, ], From 080c26b2f7bb010548c3ad6109720fbd3b357d76 Mon Sep 17 00:00:00 2001 From: almeida Date: Sat, 13 Jun 2026 10:53:12 -0300 Subject: [PATCH 02/15] feat(dodo): add new provider --- packages/dodo/README.md | 69 ++++++ packages/dodo/package.json | 36 +++ packages/dodo/src/index.ts | 333 ++++++++++++++++++++++++++ packages/dodo/src/shared/constants.ts | 72 ++++++ packages/dodo/src/shared/mapper.ts | 176 ++++++++++++++ packages/dodo/src/shared/sync.ts | 115 +++++++++ packages/dodo/src/shared/utils.ts | 231 ++++++++++++++++++ packages/dodo/src/shared/webhooks.ts | 106 ++++++++ packages/dodo/src/types.ts | 219 +++++++++++++++++ packages/dodo/test/catalog.test.ts | 82 +++++++ packages/dodo/test/customers.test.ts | 179 ++++++++++++++ packages/dodo/test/dashboard.test.ts | 226 +++++++++++++++++ packages/dodo/test/dodo.test.ts | 250 +++++++++++++++++++ packages/dodo/test/webhooks.test.ts | 203 ++++++++++++++++ packages/dodo/tsconfig.json | 11 + packages/dodo/tsdown.config.mjs | 12 + 16 files changed, 2320 insertions(+) create mode 100644 packages/dodo/README.md create mode 100644 packages/dodo/package.json create mode 100644 packages/dodo/src/index.ts create mode 100644 packages/dodo/src/shared/constants.ts create mode 100644 packages/dodo/src/shared/mapper.ts create mode 100644 packages/dodo/src/shared/sync.ts create mode 100644 packages/dodo/src/shared/utils.ts create mode 100644 packages/dodo/src/shared/webhooks.ts create mode 100644 packages/dodo/src/types.ts create mode 100644 packages/dodo/test/catalog.test.ts create mode 100644 packages/dodo/test/customers.test.ts create mode 100644 packages/dodo/test/dashboard.test.ts create mode 100644 packages/dodo/test/dodo.test.ts create mode 100644 packages/dodo/test/webhooks.test.ts create mode 100644 packages/dodo/tsconfig.json create mode 100644 packages/dodo/tsdown.config.mjs diff --git a/packages/dodo/README.md b/packages/dodo/README.md new file mode 100644 index 0000000..a8afa12 --- /dev/null +++ b/packages/dodo/README.md @@ -0,0 +1,69 @@ +
+ +

@paymesh/dodo

+ +

+ Dodo Payments for Paymesh, designed for catalog-driven global checkout flows. +

+ +

+ Launch Dodo hosted payments, manage customers, normalize webhooks, and keep the rest of your application on the standard Paymesh client contract. +

+ +

+ Installation · + Usage · + What You Get · + Important Notes +

+ +
+ +

Installation

+ +```bash +npm install paymesh @paymesh/dodo +``` + +

Usage

+ +```ts +import { createClient } from "paymesh"; +import { dodo } from "@paymesh/dodo"; + +const paymesh = createClient({ + provider: dodo({ + apiKey: process.env.DODO_PAYMENTS_API_KEY!, + webhookSecret: process.env.DODO_PAYMENTS_WEBHOOK_KEY!, + baseUrl: "https://test.dodopayments.com", + }), +}); + +const payment = await paymesh.payments.create({ + productIds: ["prod_abc123"], + currency: "BRL", + customer: { + email: "ada@example.com", + name: "Ada Lovelace", + }, + metadata: { + orderId: "order_123", + }, + successUrl: "https://example.com/billing", + cancelUrl: "https://example.com/cancel", +}); + +console.log(payment.id, payment.checkoutUrl); +``` + +

What You Get

+ +

+ @paymesh/dodo implements catalog-driven hosted payments, customer reads and upserts, catalog sync, dashboard sync helpers, webhook verification, and normalized Dodo event handling. +

+ +

Important Notes

+ +

+ Dodo can expose Pix as a hosted payment method for BRL checkouts, and this provider enables that automatically for BRL payment links. The current Paymesh pix contract is intentionally not exposed here because Dodo does not document a native backend Pix creation flow that matches Paymesh's QR-code-first shape. +

diff --git a/packages/dodo/package.json b/packages/dodo/package.json new file mode 100644 index 0000000..09cd2cf --- /dev/null +++ b/packages/dodo/package.json @@ -0,0 +1,36 @@ +{ + "name": "@paymesh/dodo", + "version": "0.0.0", + "description": "Dodo Payments provider for Paymesh.", + "type": "module", + "license": "MIT", + "sideEffects": false, + "files": [ + "dist" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "scripts": { + "build": "tsdown", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "paymesh": ">=0.0.0" + }, + "devDependencies": { + "paymesh": "workspace:*" + } +} diff --git a/packages/dodo/src/index.ts b/packages/dodo/src/index.ts new file mode 100644 index 0000000..f4df809 --- /dev/null +++ b/packages/dodo/src/index.ts @@ -0,0 +1,333 @@ +import { + type CustomerUpsertData, + defineProvider, + type PaymentCreateData, + PaymeshError, + type PaymeshEvent, + type ProviderRequestOptions, + type ProviderWebhookHandleOptions, + type ProviderWebhookHandleResult, + request, + withRaw, +} from 'paymesh'; +import { + DODO_CAPABILITIES, + DODO_LIVE_BASE_URL, + DODO_PIX_METHOD_TYPES, + DODO_TEST_BASE_URL, +} from './shared/constants'; +import { + mapDodoCatalogPrice, + mapDodoCatalogProduct, + mapDodoCustomer, + mapDodoPayment, +} from './shared/mapper'; +import { + syncDodoCustomer, + syncDodoPayment, + syncDodoSubscription, +} from './shared/sync'; +import { + buildDodoCustomerRequest, + buildDodoProductCart, + isRecord, + serializeMetadata, + verifyDodoWebhookSignature, +} from './shared/utils'; +import { + resolveDodoWebhookData, + resolveDodoWebhookEventId, + resolveDodoWebhookHook, + resolveDodoWebhookType, +} from './shared/webhooks'; +import type { + DodoCustomer, + DodoPaginatedResponse, + DodoPayment, + DodoProductListResponse, + DodoProviderOptions, + DodoWebhookPayload, +} from './types'; + +export type * from './types'; + +export const dodo = ({ + apiKey = process.env.DODO_PAYMENTS_API_KEY, + webhookSecret = process.env.DODO_PAYMENTS_WEBHOOK_KEY, + baseUrl = process.env.DODO_PAYMENTS_BASE_URL ?? DODO_LIVE_BASE_URL, + sandbox, + retry, + timeout, + fetch, +}: DodoProviderOptions = {}) => { + const resolveProviderSandbox = () => + typeof sandbox === 'boolean' ? sandbox : baseUrl === DODO_TEST_BASE_URL; + + const headers = { + authorization: `Bearer ${apiKey}`, + 'content-type': 'application/json', + }; + + const baseRequestOptions = { + baseUrl, + fetch, + headers, + retry, + timeout, + }; + + const resolveRequestOptions = ( + options?: ProviderRequestOptions, + ) => ({ + baseUrl: options?.baseUrl ?? baseRequestOptions.baseUrl, + timeout: options?.timeout ?? baseRequestOptions.timeout, + retry: options?.retry ?? baseRequestOptions.retry, + fetch: options?.fetch ?? baseRequestOptions.fetch, + headers: baseRequestOptions.headers, + }); + + return defineProvider({ + id: 'dodo', + isSandbox: resolveProviderSandbox, + capabilities: DODO_CAPABILITIES, + payments: { + async create( + data: PaymentCreateData, + options?: ProviderRequestOptions, + ) { + const customer = buildDodoCustomerRequest({ + customer: data.customer, + provider: 'dodo', + }); + const metadata = serializeMetadata(data.metadata); + const payment = await request('/payments', { + provider: 'dodo', + ...resolveRequestOptions(options), + method: 'POST', + body: { + billing: { + country: 'BR', + }, + customer, + product_cart: buildDodoProductCart({ + amount: data.amount, + productIds: data.productIds ?? [], + provider: 'dodo', + }), + allowed_payment_method_types: + data.currency?.toUpperCase() === 'BRL' + ? [...DODO_PIX_METHOD_TYPES] + : undefined, + billing_currency: data.currency?.toUpperCase(), + metadata, + payment_link: true, + return_url: data.returnUrl ?? data.successUrl ?? data.cancelUrl, + show_saved_payment_methods: Boolean(data.customer?.id), + }, + }); + + const normalized = mapDodoPayment(payment, resolveProviderSandbox()); + const result = { + id: normalized.id, + provider: normalized.provider, + sandbox: normalized.sandbox, + amount: normalized.amount, + currency: normalized.currency, + status: normalized.status, + checkoutUrl: normalized.checkoutUrl, + customer: normalized.customer, + metadata: normalized.metadata, + }; + + return withRaw(result, payment, options?.includeRaw); + }, + }, + customers: { + async get( + id: string, + options?: ProviderRequestOptions, + ) { + const customer = await request( + `/customers/${encodeURIComponent(id)}`, + { + provider: 'dodo', + ...resolveRequestOptions(options), + }, + ); + + return withRaw( + mapDodoCustomer(customer, resolveProviderSandbox()), + customer, + options?.includeRaw, + ); + }, + async upsert( + data: CustomerUpsertData, + options?: ProviderRequestOptions, + ) { + if (!data.id && !data.email) { + throw new PaymeshError({ + code: 'invalid_request', + message: 'Provider "dodo" requires "email" when creating customers', + provider: 'dodo', + }); + } + + const metadata = serializeMetadata({ + ...data.metadata, + ...(data.externalId ? { externalId: data.externalId } : {}), + }); + + const customer = await request( + data.id ? `/customers/${encodeURIComponent(data.id)}` : '/customers', + { + provider: 'dodo', + ...resolveRequestOptions(options), + method: data.id ? 'PATCH' : 'POST', + body: data.id + ? { + email: data.email, + name: data.name, + phone_number: data.phone, + metadata, + } + : { + email: data.email, + name: data.name ?? data.email!, + phone_number: data.phone, + metadata, + }, + }, + ); + + return withRaw( + mapDodoCustomer(customer, resolveProviderSandbox()), + customer, + options?.includeRaw, + ); + }, + async delete( + id: string, + options?: ProviderRequestOptions, + ) { + throw new PaymeshError({ + code: 'unsupported_capability', + message: 'Provider "dodo" does not support deleting customers.', + provider: 'dodo', + cause: { id, includeRaw: options?.includeRaw }, + }); + }, + }, + catalog: { + async list() { + const response = await request< + DodoPaginatedResponse + >('/products', { + provider: 'dodo', + ...baseRequestOptions, + query: { + page_size: 100, + }, + }); + const products = response.items ?? response.data ?? []; + + return { + products: products.map((product) => + mapDodoCatalogProduct(product, resolveProviderSandbox()), + ), + prices: products + .filter( + (product) => + typeof product.price === 'number' || + typeof product.price_detail?.price === 'number', + ) + .map((product) => + mapDodoCatalogPrice(product, resolveProviderSandbox()), + ), + }; + }, + }, + dashboard: { + getResourceUrl() { + return null; + }, + syncCustomer: (input) => + syncDodoCustomer({ + ...input, + requestOptions: baseRequestOptions, + sandbox: resolveProviderSandbox(), + }), + syncPayment: (input) => + syncDodoPayment({ + ...input, + requestOptions: baseRequestOptions, + sandbox: resolveProviderSandbox(), + }), + syncSubscription: (input) => + syncDodoSubscription({ + ...input, + requestOptions: baseRequestOptions, + sandbox: resolveProviderSandbox(), + }), + }, + webhooks: { + async verify({ request }) { + const payload = await request.text(); + return verifyDodoWebhookSignature({ + headers: request.headers, + payload, + secret: webhookSecret, + }); + }, + async handle( + options: ProviderWebhookHandleOptions, + ): Promise> { + const { request: incoming, includeRaw = false } = options; + const payload = await incoming.text(); + + let event: DodoWebhookPayload; + + try { + event = JSON.parse(payload) as DodoWebhookPayload; + } catch (error) { + throw new PaymeshError({ + code: 'webhook_parse_error', + message: 'Dodo webhook payload must be valid JSON.', + provider: 'dodo', + cause: error, + }); + } + + if (!isRecord(event) || typeof event.type !== 'string') { + throw new TypeError('Dodo webhook payload must be a JSON object.'); + } + + const type = resolveDodoWebhookType(event.type); + const hook = resolveDodoWebhookHook(type); + const data = resolveDodoWebhookData( + event, + type, + includeRaw, + resolveProviderSandbox(), + ); + const id = resolveDodoWebhookEventId(event); + + return { + deliveryId: incoming.headers.get('webhook-id') ?? undefined, + hook, + event: withRaw( + { + id, + type, + provider: 'dodo', + sandbox: resolveProviderSandbox(), + data, + }, + event, + includeRaw, + ) as PaymeshEvent, + }; + }, + }, + }); +}; diff --git a/packages/dodo/src/shared/constants.ts b/packages/dodo/src/shared/constants.ts new file mode 100644 index 0000000..7573a22 --- /dev/null +++ b/packages/dodo/src/shared/constants.ts @@ -0,0 +1,72 @@ +import type { + PaymentStatus, + PaymeshEventType, + ProviderCapabilities, +} from 'paymesh'; + +export const DODO_LIVE_BASE_URL = 'https://live.dodopayments.com'; +export const DODO_TEST_BASE_URL = 'https://test.dodopayments.com'; + +export const DODO_CAPABILITIES = { + checkout: true, + pix: false, + coupons: false, + refunds: false, + subscriptions: true, + webhooks: true, + customerPortal: false, + customers: true, +} satisfies ProviderCapabilities; + +export const DODO_PAYMENT_STATUSES: Record = { + succeeded: 'paid', + failed: 'failed', + cancelled: 'canceled', + processing: 'processing', + requires_customer_action: 'pending', + requires_merchant_action: 'pending', + requires_payment_method: 'failed', + requires_confirmation: 'pending', + requires_capture: 'processing', + partially_captured: 'processing', + partially_captured_and_capturable: 'processing', + pending: 'pending', + active: 'paid', + on_hold: 'processing', + expired: 'canceled', +}; + +export const DODO_EVENTS: Record = { + 'payment.succeeded': 'payment.succeeded', + 'payment.failed': 'payment.failed', + 'payment.processing': 'payment.created', + 'payment.cancelled': 'payment.canceled', + 'refund.succeeded': 'payment.refunded', + 'refund.failed': 'payment.failed', + 'subscription.active': 'subscription.created', + 'subscription.renewed': 'subscription.updated', + 'subscription.on_hold': 'subscription.updated', + 'subscription.paused': 'subscription.updated', + 'subscription.cancelled': 'subscription.canceled', + 'subscription.failed': 'subscription.updated', + 'subscription.expired': 'subscription.canceled', + 'subscription.plan_changed': 'subscription.updated', + 'subscription.updated': 'subscription.updated', +} satisfies Record; + +export const DODO_HOOKS: Record = { + 'payment.created': 'onPaymentCreated', + 'payment.succeeded': 'onPaymentSucceeded', + 'payment.failed': 'onPaymentFailed', + 'payment.canceled': 'onPaymentCanceled', + 'payment.refunded': 'onPaymentRefunded', + 'customer.created': 'onCustomerCreated', + 'customer.updated': 'onCustomerUpdated', + 'customer.deleted': 'onCustomerDeleted', + 'subscription.created': 'onSubscriptionCreated', + 'subscription.updated': 'onSubscriptionUpdated', + 'subscription.canceled': 'onSubscriptionCanceled', + 'checkout.completed': 'onCheckoutCompleted', +}; + +export const DODO_PIX_METHOD_TYPES = ['pix', 'credit', 'debit'] as const; diff --git a/packages/dodo/src/shared/mapper.ts b/packages/dodo/src/shared/mapper.ts new file mode 100644 index 0000000..4d18c63 --- /dev/null +++ b/packages/dodo/src/shared/mapper.ts @@ -0,0 +1,176 @@ +import type { BaseCustomer, BasePayment, BasePix } from 'paymesh'; +import type { + DodoCustomer, + DodoPayment, + DodoProductListResponse, + DodoRefund, + DodoSubscription, +} from '../types'; +import { + getDodoExternalId, + isDodoPixPayment, + mapDodoIntentStatus, + mapDodoRefundStatusToPaymentStatus, +} from './utils'; + +export function mapDodoCustomer( + customer: DodoCustomer, + sandbox: boolean, +): BaseCustomer { + return { + id: customer.customer_id, + provider: 'dodo', + sandbox, + externalId: getDodoExternalId(customer.metadata), + name: customer.name, + email: customer.email, + phone: customer.phone_number ?? void 0, + metadata: customer.metadata ?? void 0, + }; +} + +export function mapDodoPayment( + payment: DodoPayment, + sandbox: boolean, +): BasePayment | BasePix { + const customer = payment.customer + ? { + id: payment.customer.customer_id, + externalId: getDodoExternalId(payment.customer.metadata), + name: payment.customer?.name, + email: payment.customer.email, + phone: payment.customer.phone_number ?? void 0, + } + : void 0; + + if (isDodoPixPayment(payment)) { + return { + id: payment.payment_id, + provider: 'dodo', + sandbox, + amount: payment.total_amount, + currency: payment.currency.toLowerCase(), + status: mapDodoIntentStatus(payment.status), + method: 'pix', + customer, + metadata: payment.metadata ?? void 0, + checkoutUrl: payment.payment_link ?? void 0, + }; + } + + return { + id: payment.payment_id, + provider: 'dodo', + sandbox, + amount: payment.total_amount, + currency: payment.currency.toLowerCase(), + status: mapDodoIntentStatus(payment.status), + checkoutUrl: payment.payment_link ?? void 0, + customer, + metadata: payment.metadata ?? void 0, + }; +} + +export function mapDodoRefundToPayment( + refund: DodoRefund, + sandbox: boolean, +): BasePayment { + return { + id: refund.payment_id, + provider: 'dodo', + sandbox, + amount: refund.amount ?? 0, + currency: refund.currency?.toLowerCase() ?? 'usd', + status: mapDodoRefundStatusToPaymentStatus(refund.status), + customer: refund.customer + ? { + id: refund.customer.customer_id, + externalId: getDodoExternalId(refund.customer.metadata), + name: refund.customer.name, + email: refund.customer.email, + phone: refund.customer.phone_number ?? void 0, + } + : void 0, + metadata: { + ...(refund.metadata ?? {}), + refundId: refund.refund_id, + }, + }; +} + +export function mapDodoSubscription( + subscription: DodoSubscription, + sandbox: boolean, +) { + return { + id: subscription.subscription_id, + provider: 'dodo' as const, + sandbox, + customer: subscription.customer + ? { + id: subscription.customer.customer_id, + externalId: getDodoExternalId(subscription.customer.metadata), + name: subscription?.customer?.name, + email: subscription.customer.email, + phone: subscription.customer.phone_number ?? void 0, + } + : void 0, + productId: subscription.product_id, + amount: subscription.recurring_pre_tax_amount, + currency: subscription.currency.toLowerCase(), + status: subscription.status, + cancelAtPeriodEnd: subscription.cancel_at_next_billing_date, + metadata: subscription.metadata ?? void 0, + raw: subscription, + }; +} + +export function mapDodoCatalogProduct( + product: DodoProductListResponse, + sandbox: boolean, +) { + return { + id: product.product_id, + sandbox, + name: product.name ?? void 0, + description: product.description ?? void 0, + active: true, + metadata: product.metadata ?? void 0, + version: product.metadata?.version ?? void 0, + raw: product, + }; +} + +export function mapDodoCatalogPrice( + product: DodoProductListResponse, + sandbox: boolean, +) { + const price = product.price_detail; + const type = + price?.type === 'recurring_price' + ? 'recurring' + : price?.type === 'usage_based_price' + ? 'usage_based' + : 'one_time'; + + return { + id: `${product.product_id}_price`, + sandbox, + productId: product.product_id, + active: true, + type, + currency: (price?.currency ?? product.currency ?? void 0)?.toLowerCase(), + amount: price?.price ?? product.price ?? void 0, + interval: + price?.type === 'recurring_price' || price?.type === 'usage_based_price' + ? price.payment_frequency_interval?.toLowerCase() + : void 0, + intervalCount: + price?.type === 'recurring_price' || price?.type === 'usage_based_price' + ? price.payment_frequency_count + : void 0, + metadata: product.metadata ?? void 0, + version: product.metadata?.version ?? void 0, + raw: product, + }; +} diff --git a/packages/dodo/src/shared/sync.ts b/packages/dodo/src/shared/sync.ts new file mode 100644 index 0000000..0780930 --- /dev/null +++ b/packages/dodo/src/shared/sync.ts @@ -0,0 +1,115 @@ +import type { ProviderDashboardSyncInput } from 'paymesh'; +import { request, withRaw } from 'paymesh'; +import type { DodoCustomer, DodoPayment, DodoSubscription } from '../types'; +import { mapDodoCustomer, mapDodoPayment, mapDodoSubscription } from './mapper'; + +type RequestOptions = Parameters[1]; + +export const syncDodoCustomer = async ({ + id, + schema, + database, + sandbox, + requestOptions, +}: ProviderDashboardSyncInput & { + requestOptions?: RequestOptions; + sandbox: boolean; +}) => { + const customer = await request( + `/customers/${encodeURIComponent(id)}`, + { + provider: 'dodo', + ...requestOptions, + }, + ); + + const normalized = withRaw( + mapDodoCustomer(customer, sandbox), + customer, + true, + ); + + await database.repositories.customers.upsert(schema, normalized); + + return normalized; +}; + +export const syncDodoPayment = async ({ + id, + schema, + database, + sandbox, + requestOptions, +}: ProviderDashboardSyncInput & { + requestOptions?: RequestOptions; + sandbox: boolean; +}) => { + const payment = await request( + `/payments/${encodeURIComponent(id)}`, + { + provider: 'dodo', + ...requestOptions, + }, + ); + const normalizedPayment = mapDodoPayment(payment, sandbox); + const normalized = withRaw(normalizedPayment, payment, true); + + const promises = [database.repositories.invoices.upsert(schema, normalized)]; + + if ('method' in normalizedPayment && normalizedPayment.method === 'pix') + promises.push( + database.repositories.pix.upsert( + schema, + normalized as Extract, + ), + ); + + await Promise.all(promises); + + return normalized; +}; + +export const syncDodoSubscription = async ({ + id, + schema, + database, + sandbox, + requestOptions, +}: ProviderDashboardSyncInput & { + requestOptions?: RequestOptions; + sandbox: boolean; +}) => { + const subscription = await request( + `/subscriptions/${encodeURIComponent(id)}`, + { + provider: 'dodo', + ...requestOptions, + }, + ); + const type = + subscription.status === 'cancelled' || subscription.status === 'expired' + ? ('subscription.canceled' as const) + : ('subscription.updated' as const); + + const event = withRaw( + { + id, + type, + sandbox, + provider: 'dodo', + data: withRaw( + { + ...mapDodoSubscription(subscription, sandbox), + }, + subscription, + true, + ), + }, + subscription, + true, + ); + + await database.repositories.subscriptions.upsert(schema, event); + + return event.data as unknown as Record; +}; diff --git a/packages/dodo/src/shared/utils.ts b/packages/dodo/src/shared/utils.ts new file mode 100644 index 0000000..1763b67 --- /dev/null +++ b/packages/dodo/src/shared/utils.ts @@ -0,0 +1,231 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { PaymeshError } from 'paymesh'; +import type { + DodoIntentStatus, + DodoMetadata, + DodoPayment, + DodoRefund, + DodoSubscription, +} from '../types'; +import { DODO_PAYMENT_STATUSES } from './constants'; + +export function getDodoExternalId(metadata?: DodoMetadata | null) { + return typeof metadata?.externalId === 'string' && + metadata.externalId.length > 0 + ? metadata.externalId + : undefined; +} + +export function serializeMetadata( + metadata?: Record, +) { + const entries = Object.entries(metadata ?? {}).filter( + ([, value]) => value !== null, + ); + if (entries.length === 0) return undefined; + + return Object.fromEntries( + entries.map(([key, value]) => [key, String(value)]), + ) as Record; +} + +export function buildDodoCustomerRequest(input?: { + customer?: { + email?: string; + id?: string; + name?: string; + phone?: string; + }; + provider?: string; +}) { + const customer = input?.customer; + + if (!customer?.id && !customer?.email) { + throw new PaymeshError({ + code: 'invalid_request', + message: + 'Provider "dodo" requires either "customer.id" or "customer.email" when creating payments.', + provider: input?.provider ?? 'dodo', + }); + } + + if (customer.id) { + return { customer_id: customer.id }; + } + + return { + email: customer.email!, + name: customer.name ?? customer.email!, + phone_number: customer.phone, + }; +} + +export function isDodoPixPayment( + payment: + | DodoPayment + | Pick + | { + currency?: string | null; + payment_method?: string | null; + payment_method_type?: string | null; + }, +) { + return ( + 'payment_method_type' in payment && + (payment.payment_method_type === 'pix' || + payment.payment_method === 'real_time_payment') + ); +} + +export function mapDodoIntentStatus(status?: DodoIntentStatus | null) { + return !status ? 'pending' : (DODO_PAYMENT_STATUSES[status] ?? 'pending'); +} + +export function parseDodoWebhookSecret(secret: string) { + const value = secret.startsWith('whsec_') + ? secret.slice('whsec_'.length) + : secret; + return Buffer.from(value, 'base64'); +} + +export function signDodoWebhook(options: { + payload: string; + secret: string; + timestamp: string; + webhookId: string; +}) { + const body = `${options.webhookId}.${options.timestamp}.${options.payload}`; + const expected = createHmac('sha256', parseDodoWebhookSecret(options.secret)) + .update(body) + .digest('base64'); + + return `v1,${expected}`; +} + +export function verifyDodoWebhookSignature(options: { + headers: Headers; + payload: string; + secret?: string; +}) { + if (!options.secret) return false; + + const webhookId = options.headers.get('webhook-id'); + const timestamp = options.headers.get('webhook-timestamp'); + const signatureHeader = options.headers.get('webhook-signature'); + + if (!webhookId || !timestamp || !signatureHeader) return false; + + const timestampValue = Number.parseInt(timestamp, 10); + if (!Number.isFinite(timestampValue)) return false; + + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - timestampValue) > 5 * 60) return false; + + const expected = signDodoWebhook({ + payload: options.payload, + secret: options.secret, + timestamp, + webhookId, + }); + + const expectedSignature = expected.split(',')[1]; + if (!expectedSignature) return false; + + for (const part of signatureHeader.split(/\s+/)) { + const [version, signature] = part.split(','); + if (version !== 'v1' || !signature) continue; + if ( + signature.length === expectedSignature.length && + timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature)) + ) { + return true; + } + } + + return false; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function resolveDodoSubscriptionEventType( + subscription: DodoSubscription, +) { + if ( + subscription.status === 'cancelled' || + subscription.status === 'expired' + ) { + return 'subscription.canceled' as const; + } + + return 'subscription.updated' as const; +} + +export function ensureDodoProductIds(productIds?: string[]) { + if (!productIds || productIds.length === 0) { + throw new PaymeshError({ + code: 'invalid_request', + message: + 'Provider "dodo" requires at least one product id in "productIds"', + provider: 'dodo', + }); + } +} + +export function buildDodoProductCart(input: { + amount?: number; + productIds: string[]; + provider?: string; +}) { + ensureDodoProductIds(input.productIds); + + if (typeof input.amount === 'number' && input.productIds.length > 1) + throw new PaymeshError({ + code: 'invalid_request', + message: + 'Provider "dodo" only accepts "amount" when exactly one product id is provided.', + provider: input.provider ?? 'dodo', + }); + + return input.productIds.map((productId) => ({ + product_id: productId, + quantity: 1, + amount: typeof input.amount === 'number' ? input.amount : undefined, + })); +} + +export function resolveDodoCustomerFromInput(input?: { + email?: string; + externalId?: string; + id?: string; + name?: string; + phone?: string; +}) { + if (!input) return undefined; + + if ( + !input.id && + !input.email && + !input.externalId && + !input.name && + !input.phone + ) { + return undefined; + } + + return { + id: input.id, + externalId: input.externalId, + name: input.name, + email: input.email, + phone: input.phone, + }; +} + +export function mapDodoRefundStatusToPaymentStatus(status?: string | null) { + if (status === 'succeeded') return 'refunded' as const; + if (status === 'failed') return 'failed' as const; + if (status === 'review') return 'processing' as const; + return 'pending' as const; +} diff --git a/packages/dodo/src/shared/webhooks.ts b/packages/dodo/src/shared/webhooks.ts new file mode 100644 index 0000000..74987e8 --- /dev/null +++ b/packages/dodo/src/shared/webhooks.ts @@ -0,0 +1,106 @@ +import type { PaymeshEventType } from 'paymesh'; +import { withRaw } from 'paymesh'; +import type { DodoWebhookPayload } from '../types'; +import { DODO_EVENTS, DODO_HOOKS } from './constants'; +import { + mapDodoPayment, + mapDodoRefundToPayment, + mapDodoSubscription, +} from './mapper'; + +function isDodoPaymentWebhook(event: DodoWebhookPayload): event is Extract< + DodoWebhookPayload, + { + type: + | 'payment.succeeded' + | 'payment.failed' + | 'payment.processing' + | 'payment.cancelled'; + } +> { + return ( + event.type === 'payment.succeeded' || + event.type === 'payment.failed' || + event.type === 'payment.processing' || + event.type === 'payment.cancelled' + ); +} + +function isDodoRefundWebhook( + event: DodoWebhookPayload, +): event is Extract< + DodoWebhookPayload, + { type: 'refund.succeeded' | 'refund.failed' } +> { + return event.type === 'refund.succeeded' || event.type === 'refund.failed'; +} + +function isDodoSubscriptionWebhook(event: DodoWebhookPayload): event is Extract< + DodoWebhookPayload, + { + type: + | 'subscription.active' + | 'subscription.renewed' + | 'subscription.on_hold' + | 'subscription.paused' + | 'subscription.cancelled' + | 'subscription.failed' + | 'subscription.expired' + | 'subscription.plan_changed' + | 'subscription.updated'; + } +> { + return event.type.startsWith('subscription.'); +} + +export function resolveDodoWebhookType(type: string): PaymeshEventType { + return DODO_EVENTS[type] ?? 'payment.created'; +} + +export function resolveDodoWebhookHook(type: PaymeshEventType) { + return DODO_HOOKS[type]; +} + +export function resolveDodoWebhookData( + event: DodoWebhookPayload, + _type: PaymeshEventType, + includeRaw: boolean, + sandbox: boolean, +) { + if (isDodoPaymentWebhook(event)) { + return withRaw(mapDodoPayment(event.data, sandbox), event.data, includeRaw); + } + + if (isDodoRefundWebhook(event)) { + return withRaw( + mapDodoRefundToPayment(event.data, sandbox), + event.data, + includeRaw, + ); + } + + if (isDodoSubscriptionWebhook(event)) { + return withRaw( + mapDodoSubscription(event.data, sandbox), + event.data, + includeRaw, + ); + } + + return withRaw( + { + ...event.data, + provider: 'dodo', + sandbox, + }, + event.data, + includeRaw, + ); +} + +export function resolveDodoWebhookEventId(event: DodoWebhookPayload) { + if (isDodoPaymentWebhook(event)) return event.data.payment_id; + if (isDodoRefundWebhook(event)) return event.data.refund_id; + if (isDodoSubscriptionWebhook(event)) return event.data.subscription_id; + return `${event.type}:${event.timestamp}`; +} diff --git a/packages/dodo/src/types.ts b/packages/dodo/src/types.ts new file mode 100644 index 0000000..ebacef4 --- /dev/null +++ b/packages/dodo/src/types.ts @@ -0,0 +1,219 @@ +import type { RetryOptions } from 'paymesh'; + +export interface DodoProviderOptions { + apiKey?: string; + sandbox?: boolean; + webhookSecret?: string; + baseUrl?: string; + retry?: RetryOptions; + timeout?: number; + fetch?: typeof fetch; +} + +export type DodoMetadata = Record | null; + +export interface DodoCustomer { + business_id?: string; + created_at?: string; + customer_id: string; + email: string; + name: string; + metadata?: DodoMetadata; + phone_number?: string | null; +} + +export type DodoIntentStatus = + | 'succeeded' + | 'failed' + | 'cancelled' + | 'processing' + | 'requires_customer_action' + | 'requires_merchant_action' + | 'requires_payment_method' + | 'requires_confirmation' + | 'requires_capture' + | 'partially_captured' + | 'partially_captured_and_capturable'; + +export interface DodoPayment { + billing?: { + country?: string; + city?: string | null; + state?: string | null; + street?: string | null; + zipcode?: string | null; + }; + brand_id?: string; + business_id?: string; + created_at?: string; + currency: string; + customer: DodoCustomer; + digital_products_delivered?: boolean; + disputes?: unknown[]; + metadata: DodoMetadata; + payment_id: string; + payment_provider?: 'stripe' | 'adyen' | 'dodo'; + refunds?: DodoRefund[]; + retry_attempt?: number; + settlement_amount?: number; + settlement_currency?: string; + total_amount: number; + card_holder_name?: string | null; + card_issuing_country?: string | null; + card_last_four?: string | null; + card_network?: string | null; + card_type?: string | null; + checkout_session_id?: string | null; + custom_field_responses?: Array<{ key: string; value: string }> | null; + discount_id?: string | null; + discounts?: unknown[] | null; + error_code?: string | null; + error_message?: string | null; + invoice_id?: string | null; + invoice_url?: string | null; + payment_link?: string | null; + payment_method?: string | null; + payment_method_type?: string | null; + product_cart?: Array<{ + product_id: string; + quantity: number; + amount?: number | null; + }> | null; + refund_status?: string | null; + status?: DodoIntentStatus | null; + subscription_id?: string | null; +} + +export interface DodoRefund { + amount?: number | null; + business_id?: string; + created_at?: string; + customer?: DodoCustomer; + is_partial?: boolean; + metadata?: DodoMetadata; + payment_id: string; + refund_id: string; + status: 'succeeded' | 'failed' | 'pending' | 'review'; + currency?: string | null; + reason?: string | null; +} + +export interface DodoSubscription { + addons?: Array<{ + addon_id: string; + name?: string; + quantity: number; + }>; + billing?: DodoPayment['billing']; + cancel_at_next_billing_date: boolean; + created_at?: string; + currency: string; + customer: DodoCustomer; + metadata: DodoMetadata; + next_billing_date?: string; + on_demand?: boolean; + payment_frequency_count?: number; + payment_frequency_interval?: string; + previous_billing_date?: string; + product_id: string; + quantity: number; + recurring_pre_tax_amount: number; + status: 'pending' | 'active' | 'on_hold' | 'cancelled' | 'failed' | 'expired'; + subscription_id: string; + subscription_period_count?: number; + subscription_period_interval?: string; + tax_inclusive?: boolean; + trial_period_days?: number; + cancellation_comment?: string | null; + cancellation_feedback?: string | null; + cancelled_at?: string | null; + custom_field_responses?: Array<{ key: string; value: string }> | null; + customer_business_name?: string | null; + discount_id?: string | null; + discounts?: unknown[] | null; + expires_at?: string | null; + payment_method_id?: string | null; + scheduled_change?: unknown | null; + tax_id?: string | null; +} + +export interface DodoCheckoutResponse { + session_id: string; + checkout_url?: string | null; + client_secret?: string | null; + payment_id?: string | null; + publishable_key?: string | null; +} + +export interface DodoProductPrice { + type: 'one_time_price' | 'recurring_price' | 'usage_based_price'; + currency: string; + price: number; + payment_frequency_count?: number; + payment_frequency_interval?: 'Day' | 'Week' | 'Month' | 'Year'; + subscription_period_count?: number; + subscription_period_interval?: 'Day' | 'Week' | 'Month' | 'Year'; + tax_inclusive?: boolean | null; +} + +export interface DodoProductListResponse { + business_id?: string; + created_at?: string; + entitlements?: unknown[]; + is_recurring: boolean; + metadata: DodoMetadata; + product_id: string; + tax_category?: string; + updated_at?: string; + currency?: string | null; + description?: string | null; + image?: string | null; + name?: string | null; + price?: number | null; + price_detail?: DodoProductPrice | null; + tax_inclusive?: boolean | null; +} + +export interface DodoPaginatedResponse { + items?: T[]; + data?: T[]; +} + +export type DodoWebhookPayload = + | { + business_id: string; + data: DodoPayment; + timestamp: string; + type: + | 'payment.succeeded' + | 'payment.failed' + | 'payment.processing' + | 'payment.cancelled'; + } + | { + business_id: string; + data: DodoRefund; + timestamp: string; + type: 'refund.succeeded' | 'refund.failed'; + } + | { + business_id: string; + data: DodoSubscription; + timestamp: string; + type: + | 'subscription.active' + | 'subscription.renewed' + | 'subscription.on_hold' + | 'subscription.paused' + | 'subscription.cancelled' + | 'subscription.failed' + | 'subscription.expired' + | 'subscription.plan_changed' + | 'subscription.updated'; + } + | { + business_id?: string; + data: Record; + timestamp: string; + type: string; + }; diff --git a/packages/dodo/test/catalog.test.ts b/packages/dodo/test/catalog.test.ts new file mode 100644 index 0000000..65393a0 --- /dev/null +++ b/packages/dodo/test/catalog.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'bun:test'; +import { dodo } from '../src'; + +describe('dodo catalog', () => { + test('lists products and maps one-time and recurring prices', async () => { + const provider = dodo({ + apiKey: 'dodo_test_123', + baseUrl: 'https://test.dodopayments.com', + fetch: (async (input) => { + expect(String(input)).toBe( + 'https://test.dodopayments.com/products?page_size=100', + ); + + return Response.json({ + items: [ + { + product_id: 'prod_one', + name: 'One Time', + description: 'One-time product', + is_recurring: false, + currency: 'USD', + price: 4900, + price_detail: { + type: 'one_time_price', + currency: 'USD', + price: 4900, + }, + metadata: { + version: 'v1', + }, + }, + { + product_id: 'prod_recurring', + name: 'Recurring', + description: 'Recurring product', + is_recurring: true, + currency: 'EUR', + price: 2900, + price_detail: { + type: 'recurring_price', + currency: 'EUR', + price: 2900, + payment_frequency_count: 1, + payment_frequency_interval: 'Month', + subscription_period_count: 1, + subscription_period_interval: 'Month', + }, + metadata: {}, + }, + ], + }); + }) as typeof fetch, + }); + + const catalog = await provider.catalog?.list(); + + expect(catalog?.products).toHaveLength(2); + expect(catalog?.products[0]).toMatchObject({ + id: 'prod_one', + sandbox: true, + name: 'One Time', + description: 'One-time product', + active: true, + version: 'v1', + }); + expect(catalog?.prices).toHaveLength(2); + expect(catalog?.prices[0]).toMatchObject({ + id: 'prod_one_price', + type: 'one_time', + currency: 'usd', + amount: 4900, + }); + expect(catalog?.prices[1]).toMatchObject({ + id: 'prod_recurring_price', + type: 'recurring', + currency: 'eur', + amount: 2900, + interval: 'month', + intervalCount: 1, + }); + }); +}); diff --git a/packages/dodo/test/customers.test.ts b/packages/dodo/test/customers.test.ts new file mode 100644 index 0000000..c783432 --- /dev/null +++ b/packages/dodo/test/customers.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from 'bun:test'; +import { createClient } from 'paymesh'; +import { dodo } from '../src'; + +function expectType(_value: T) {} + +describe('dodo customers', () => { + test('manages Dodo customers through the client', async () => { + const requests: Array<{ + input: string; + method?: string; + body?: Record; + }> = []; + const provider = dodo({ + apiKey: 'dodo_test_123', + }); + const client = createClient({ + provider, + baseUrl: 'https://dodo.customers.test', + timeout: 1234, + fetch: (async (input, init) => { + requests.push({ + input: String(input), + method: init?.method, + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }); + + if (String(input).endsWith('/customers') && init?.method === 'POST') { + return Response.json({ + customer_id: 'cus_create', + email: 'ana@example.com', + name: 'Ana', + phone_number: '+5511999999999', + metadata: { + externalId: 'user_ext_123', + plan: 'pro', + }, + }); + } + + if (String(input).endsWith('/customers/cus_create')) { + if (init?.method === 'PATCH') { + return Response.json({ + customer_id: 'cus_create', + email: 'ana@example.com', + name: 'Ana Silva', + phone_number: '+5511999999999', + metadata: { + externalId: 'user_ext_123', + plan: 'business', + }, + }); + } + + return Response.json({ + customer_id: 'cus_create', + email: 'ana@example.com', + name: 'Ana', + phone_number: '+5511999999999', + metadata: { + externalId: 'user_ext_123', + plan: 'pro', + }, + }); + } + + return new Response('not found', { status: 404 }); + }) as typeof fetch, + }); + + const created = await client.customers.upsert({ + name: 'Ana', + email: 'ana@example.com', + phone: '+5511999999999', + externalId: 'user_ext_123', + metadata: { + plan: 'pro', + }, + }); + const found = await client.customers.get('cus_create'); + const updated = await client.customers.upsert({ + id: 'cus_create', + name: 'Ana Silva', + metadata: { + plan: 'business', + }, + }); + + expect(requests[0]).toMatchObject({ + input: 'https://dodo.customers.test/customers', + method: 'POST', + }); + expect(requests[0]?.body).toEqual({ + email: 'ana@example.com', + name: 'Ana', + phone_number: '+5511999999999', + metadata: { + plan: 'pro', + externalId: 'user_ext_123', + }, + }); + expect(requests[1]).toMatchObject({ + input: 'https://dodo.customers.test/customers/cus_create', + method: undefined, + }); + expect(requests[2]).toMatchObject({ + input: 'https://dodo.customers.test/customers/cus_create', + method: 'PATCH', + }); + expect(requests[2]?.body).toEqual({ + email: undefined, + name: 'Ana Silva', + phone_number: undefined, + metadata: { + plan: 'business', + }, + }); + expect(created).toMatchObject({ + id: 'cus_create', + provider: 'dodo', + externalId: 'user_ext_123', + name: 'Ana', + email: 'ana@example.com', + phone: '+5511999999999', + metadata: { + externalId: 'user_ext_123', + plan: 'pro', + }, + }); + expect(found.id).toBe('cus_create'); + expect(updated.name).toBe('Ana Silva'); + }); + + test('supports raw customer payloads and explicit delete failures', async () => { + const provider = dodo({ + apiKey: 'dodo_test_123', + baseUrl: 'https://dodo.customers.test', + fetch: (async () => + Response.json({ + customer_id: 'cus_raw', + email: 'ana@example.com', + name: 'Ana', + metadata: { + externalId: 'user_ext_123', + }, + })) as unknown as typeof fetch, + }); + const rawClient = createClient({ provider, includeRaw: true }); + + const customer = await rawClient.customers.get('cus_raw'); + + expectType(customer.raw); + expect(customer.raw).toMatchObject({ + customer_id: 'cus_raw', + }); + expect(customer.externalId).toBe('user_ext_123'); + + await expect(rawClient.customers.delete('cus_raw')).rejects.toMatchObject({ + code: 'unsupported_capability', + message: 'Provider "dodo" does not support deleting customers.', + provider: 'dodo', + }); + }); + + test('requires email when creating customers', async () => { + const provider = dodo({ + apiKey: 'dodo_test_123', + }); + + await expect( + provider.customers.upsert({ + name: 'Ana', + }), + ).rejects.toMatchObject({ + code: 'invalid_request', + message: 'Provider "dodo" requires "email" when creating customers', + }); + }); +}); diff --git a/packages/dodo/test/dashboard.test.ts b/packages/dodo/test/dashboard.test.ts new file mode 100644 index 0000000..7a0aca5 --- /dev/null +++ b/packages/dodo/test/dashboard.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, test } from 'bun:test'; +import { + type CompiledQuery, + type PaymeshDatabaseDriver, + resolveDatabaseSchema, +} from 'paymesh'; +import { dodo } from '../src'; + +describe('dodo dashboard adapter', () => { + test('syncs customer, payment, pix-shaped payment, and subscriptions', async () => { + let syncedCustomerId: string | undefined; + let syncedInvoiceId: string | undefined; + let syncedPixId: string | undefined; + let syncedSubscriptionId: string | undefined; + const provider = dodo({ + apiKey: 'dodo_test_123', + baseUrl: 'https://test.dodopayments.com', + fetch: (async (input) => { + if (String(input).endsWith('/customers/cus_123')) { + return Response.json({ + customer_id: 'cus_123', + email: 'ana@example.com', + name: 'Ana', + metadata: { + externalId: 'user_123', + }, + }); + } + + if (String(input).endsWith('/payments/pay_123')) { + return Response.json({ + payment_id: 'pay_123', + total_amount: 4200, + currency: 'USD', + customer: { + customer_id: 'cus_123', + email: 'ana@example.com', + name: 'Ana', + }, + metadata: {}, + payment_link: 'https://pay.dodo.test/pay_123', + status: 'succeeded', + }); + } + + if (String(input).endsWith('/payments/pay_pix_123')) { + return Response.json({ + payment_id: 'pay_pix_123', + total_amount: 3100, + currency: 'BRL', + customer: { + customer_id: 'cus_123', + email: 'ana@example.com', + name: 'Ana', + }, + metadata: {}, + payment_link: 'https://pay.dodo.test/pay_pix_123', + payment_method_type: 'pix', + status: 'processing', + }); + } + + if (String(input).endsWith('/subscriptions/sub_123')) { + return Response.json({ + subscription_id: 'sub_123', + product_id: 'prod_123', + recurring_pre_tax_amount: 3900, + currency: 'USD', + status: 'active', + cancel_at_next_billing_date: false, + customer: { + customer_id: 'cus_123', + email: 'ana@example.com', + name: 'Ana', + }, + metadata: {}, + }); + } + + return new Response('not found', { status: 404 }); + }) as typeof fetch, + }); + const database = createDatabaseStub({ + onCustomerUpsert(id) { + syncedCustomerId = id; + }, + onInvoiceUpsert(id) { + syncedInvoiceId = id; + }, + onPixUpsert(id) { + syncedPixId = id; + }, + onSubscriptionUpsert(id) { + syncedSubscriptionId = id; + }, + }); + + const customer = await provider.dashboard?.syncCustomer?.({ + database, + id: 'cus_123', + schema: createSchema(), + }); + const payment = await provider.dashboard?.syncPayment?.({ + database, + id: 'pay_123', + schema: createSchema(), + }); + const pix = await provider.dashboard?.syncPayment?.({ + database, + id: 'pay_pix_123', + schema: createSchema(), + }); + const subscription = await provider.dashboard?.syncSubscription?.({ + database, + id: 'sub_123', + schema: createSchema(), + }); + + expect( + provider.dashboard?.getResourceUrl?.({ type: 'payment', id: 'pay_123' }), + ).toBeNull(); + expect(customer?.id).toBe('cus_123'); + expect(payment?.id).toBe('pay_123'); + expect(pix).toMatchObject({ + id: 'pay_pix_123', + method: 'pix', + status: 'processing', + }); + expect(subscription?.id).toBe('sub_123'); + expect(syncedCustomerId).toBe('cus_123'); + expect(syncedInvoiceId).toBe('pay_pix_123'); + expect(syncedPixId).toBe('pay_pix_123'); + expect(syncedSubscriptionId).toBe('sub_123'); + }); +}); + +function createDatabaseStub(options: { + onCustomerUpsert(id: string): void; + onInvoiceUpsert(id: string): void; + onPixUpsert(id: string): void; + onSubscriptionUpsert(id: string): void; +}) { + return { + id: 'stub-db', + type: 'database', + dialect: 'postgres', + persistRaw: true, + repositories: { + customers: { + async findByProviderId() { + return null; + }, + async upsert(_schema: unknown, customer: { id: string }) { + options.onCustomerUpsert(customer.id); + }, + async list() { + return { data: [], next: null, previous: null, total: 0 }; + }, + async markDeleted() {}, + }, + pix: { + async findByProviderId() { + return null; + }, + async upsert(_schema: unknown, pix: { id: string }) { + options.onPixUpsert(pix.id); + }, + }, + checkouts: { + async findByProviderId() { + return null; + }, + async upsert() {}, + }, + invoices: { + async findByProviderId() { + return null; + }, + async upsert(_schema: unknown, payment: { id: string }) { + options.onInvoiceUpsert(payment.id); + }, + }, + subscriptions: { + async findByProviderId() { + return null; + }, + async upsert(_schema: unknown, event: { data?: { id?: string } }) { + options.onSubscriptionUpsert(event.data?.id ?? 'missing'); + }, + }, + webhookEvents: { + async acquire() { + return { duplicate: false }; + }, + async markProcessed() {}, + async markFailed() {}, + }, + products: { + async upsertMany() {}, + }, + prices: { + async upsertMany() {}, + }, + migrations: { + async ensureTable() {}, + async listApplied() { + return []; + }, + async recordApplied() {}, + }, + }, + async query(_query: CompiledQuery) { + return [] as Row[]; + }, + async execute(_query: CompiledQuery) {}, + async transaction( + callback: (database: PaymeshDatabaseDriver) => Promise, + ) { + return callback(this as unknown as PaymeshDatabaseDriver); + }, + } as unknown as PaymeshDatabaseDriver; +} + +function createSchema() { + return resolveDatabaseSchema(); +} diff --git a/packages/dodo/test/dodo.test.ts b/packages/dodo/test/dodo.test.ts new file mode 100644 index 0000000..cdd221d --- /dev/null +++ b/packages/dodo/test/dodo.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, test } from 'bun:test'; +import { createClient } from 'paymesh'; +import { dodo } from '../src'; + +function expectType(_value: T) {} + +describe('dodo provider', () => { + test('creates a hosted payment link using Dodo product carts', async () => { + const provider = dodo({ + apiKey: 'dodo_test_123', + baseUrl: 'https://test.dodopayments.com', + fetch: (async (input, init) => { + expect(String(input)).toBe('https://test.dodopayments.com/payments'); + expect(init?.method).toBe('POST'); + expect(init?.headers).toEqual({ + authorization: 'Bearer dodo_test_123', + 'content-type': 'application/json', + }); + + const body = JSON.parse(String(init?.body)); + expect(body).toEqual({ + billing: { + country: 'BR', + }, + customer: { + email: 'ana@example.com', + name: 'Ana', + phone_number: '+5511999999999', + }, + product_cart: [{ product_id: 'prod_123', quantity: 1, amount: 4900 }], + allowed_payment_method_types: ['pix', 'credit', 'debit'], + billing_currency: 'BRL', + metadata: { + externalId: 'user_123', + orderId: 'order_123', + }, + payment_link: true, + return_url: 'https://app.test/success', + show_saved_payment_methods: false, + }); + + return Response.json({ + payment_id: 'pay_123', + total_amount: 4900, + currency: 'BRL', + payment_link: 'https://pay.dodo.test/pay_123', + metadata: { + externalId: 'user_123', + orderId: 'order_123', + }, + customer: { + customer_id: 'cus_123', + email: 'ana@example.com', + name: 'Ana', + phone_number: '+5511999999999', + metadata: { + externalId: 'user_123', + }, + }, + status: 'processing', + }); + }) as typeof fetch, + }); + + const payment = await provider.payments.create({ + amount: 4900, + currency: 'BRL', + productIds: ['prod_123'], + customer: { + email: 'ana@example.com', + name: 'Ana', + phone: '+5511999999999', + externalId: 'user_123', + }, + successUrl: 'https://app.test/success', + metadata: { + externalId: 'user_123', + orderId: 'order_123', + }, + }); + + expect(payment).toMatchObject({ + id: 'pay_123', + provider: 'dodo', + sandbox: true, + amount: 4900, + currency: 'brl', + status: 'processing', + checkoutUrl: 'https://pay.dodo.test/pay_123', + customer: { + id: 'cus_123', + email: 'ana@example.com', + name: 'Ana', + externalId: 'user_123', + }, + metadata: { + externalId: 'user_123', + orderId: 'order_123', + }, + }); + expect(payment.raw).toBeNull(); + }); + + test('uses client request options and supports raw payload toggles', async () => { + let attempts = 0; + let timeoutSignal: AbortSignal | undefined; + const provider = dodo({ + apiKey: 'dodo_test_123', + }); + const client = createClient({ + provider, + baseUrl: 'https://dodo.client.test', + timeout: 1234, + retry: { + max: 1, + }, + fetch: (async (input, init) => { + attempts += 1; + timeoutSignal = init?.signal as AbortSignal; + + expect(String(input)).toBe('https://dodo.client.test/payments'); + + if (attempts === 1) { + return new Response('busy', { status: 500 }); + } + + return Response.json({ + payment_id: 'pay_retry', + total_amount: 1200, + currency: 'USD', + customer: { + customer_id: 'cus_retry', + email: 'retry@example.com', + name: 'Retry', + }, + metadata: {}, + payment_link: 'https://pay.dodo.test/retry', + status: 'succeeded', + }); + }) as typeof fetch, + }); + const rawClient = createClient({ provider, includeRaw: true }); + + const payment = await client.payments.create({ + productIds: ['prod_123'], + customer: { + email: 'retry@example.com', + }, + }); + const rawPayment = await rawClient.payments.create( + { + productIds: ['prod_123'], + customer: { + email: 'raw@example.com', + }, + }, + { + includeRaw: true, + baseUrl: 'https://dodo.raw.test', + fetch: (async () => + Response.json({ + payment_id: 'pay_raw', + total_amount: 900, + currency: 'USD', + customer: { + customer_id: 'cus_raw', + email: 'raw@example.com', + name: 'Raw', + }, + metadata: {}, + payment_link: 'https://pay.dodo.test/raw', + status: 'processing', + })) as unknown as typeof fetch, + }, + ); + + expect(attempts).toBe(2); + expect(timeoutSignal).toBeInstanceOf(AbortSignal); + expect(payment.status).toBe('paid'); + expect(payment.raw).toBeNull(); + expectType(rawPayment.raw); + expect(rawPayment.raw).toMatchObject({ + payment_id: 'pay_raw', + }); + }); + + test('requires productIds and customer identity', async () => { + const provider = dodo({ + apiKey: 'dodo_test_123', + }); + + expect( + provider.payments.create({ + customer: { + email: 'ana@example.com', + }, + }), + ).rejects.toMatchObject({ + code: 'invalid_request', + message: + 'Provider "dodo" requires at least one product id in "productIds"', + }); + + expect( + provider.payments.create({ + productIds: ['prod_1', 'prod_2'], + amount: 1000, + customer: { + email: 'ana@example.com', + }, + }), + ).rejects.toMatchObject({ + code: 'invalid_request', + message: + 'Provider "dodo" only accepts "amount" when exactly one product id is provided.', + }); + + expect( + provider.payments.create({ + productIds: ['prod_123'], + }), + ).rejects.toMatchObject({ + code: 'invalid_request', + message: + 'Provider "dodo" requires either "customer.id" or "customer.email" when creating payments.', + }); + }); + + test('infers sandbox from the configured base URL', () => { + expect(dodo({ baseUrl: 'https://test.dodopayments.com' }).isSandbox()).toBe( + true, + ); + expect(dodo({ baseUrl: 'https://live.dodopayments.com' }).isSandbox()).toBe( + false, + ); + }); + + test('advertises the supported Dodo capability surface', () => { + expect(dodo().capabilities).toEqual({ + checkout: true, + pix: false, + coupons: false, + refunds: false, + subscriptions: true, + webhooks: true, + customerPortal: false, + customers: true, + }); + }); +}); diff --git a/packages/dodo/test/webhooks.test.ts b/packages/dodo/test/webhooks.test.ts new file mode 100644 index 0000000..d9d7211 --- /dev/null +++ b/packages/dodo/test/webhooks.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, test } from 'bun:test'; +import { dodo } from '../src'; +import { signDodoWebhook } from '../src/shared/utils'; + +describe('dodo webhooks', () => { + test('verifies valid webhook signatures and handles payment events', async () => { + const provider = dodo({ + webhookSecret: 'whsec_c2VjcmV0X2Rlc2lnbg==', + baseUrl: 'https://test.dodopayments.com', + }); + const request = dodoWebhookRequest( + { + business_id: 'biz_123', + type: 'payment.succeeded', + timestamp: new Date().toISOString(), + data: { + payment_id: 'pay_123', + total_amount: 1200, + currency: 'USD', + customer: { + customer_id: 'cus_123', + email: 'ana@example.com', + name: 'Ana', + metadata: { + externalId: 'user_123', + }, + }, + metadata: {}, + payment_link: 'https://pay.dodo.test/pay_123', + status: 'succeeded', + }, + }, + 'whsec_c2VjcmV0X2Rlc2lnbg==', + ); + + const valid = await provider.webhooks?.verify({ + request: request.clone(), + }); + const handled = await provider.webhooks?.handle({ + request, + includeRaw: true, + }); + + expect(valid).toBe(true); + expect(handled?.deliveryId).toBe('msg_123'); + expect(handled?.hook).toBe('onPaymentSucceeded'); + expect(handled?.event).toMatchObject({ + id: 'pay_123', + type: 'payment.succeeded', + provider: 'dodo', + sandbox: true, + data: { + id: 'pay_123', + status: 'paid', + customer: { + id: 'cus_123', + externalId: 'user_123', + }, + raw: { + payment_id: 'pay_123', + }, + }, + raw: { + type: 'payment.succeeded', + }, + }); + }); + + test('maps Pix-shaped payment and subscription cancellation webhooks', async () => { + const provider = dodo({ + webhookSecret: 'whsec_c2VjcmV0X2Rlc2lnbg==', + baseUrl: 'https://test.dodopayments.com', + }); + const pixHandled = await provider.webhooks?.handle({ + request: new Request('https://app.test/webhooks', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'webhook-id': 'msg_pix', + }, + body: JSON.stringify({ + business_id: 'biz_123', + type: 'payment.processing', + timestamp: new Date().toISOString(), + data: { + payment_id: 'pay_pix_123', + total_amount: 3100, + currency: 'BRL', + customer: { + customer_id: 'cus_123', + email: 'ana@example.com', + name: 'Ana', + }, + metadata: {}, + payment_link: 'https://pay.dodo.test/pay_pix_123', + payment_method_type: 'pix', + status: 'processing', + }, + }), + }), + }); + const subscriptionHandled = await provider.webhooks?.handle({ + request: new Request('https://app.test/webhooks', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'webhook-id': 'msg_sub', + }, + body: JSON.stringify({ + business_id: 'biz_123', + type: 'subscription.cancelled', + timestamp: new Date().toISOString(), + data: { + subscription_id: 'sub_123', + product_id: 'prod_123', + recurring_pre_tax_amount: 3900, + currency: 'USD', + status: 'cancelled', + cancel_at_next_billing_date: true, + customer: { + customer_id: 'cus_123', + email: 'ana@example.com', + name: 'Ana', + }, + metadata: {}, + }, + }), + }), + }); + + expect(pixHandled?.event).toMatchObject({ + type: 'payment.created', + data: { + id: 'pay_pix_123', + method: 'pix', + status: 'processing', + }, + }); + expect(pixHandled?.hook).toBe('onPaymentCreated'); + expect(subscriptionHandled?.event).toMatchObject({ + id: 'sub_123', + type: 'subscription.canceled', + data: { + id: 'sub_123', + status: 'cancelled', + }, + }); + expect(subscriptionHandled?.hook).toBe('onSubscriptionCanceled'); + }); + + test('rejects invalid webhook signatures', async () => { + const provider = dodo({ + webhookSecret: 'whsec_c2VjcmV0X2Rlc2lnbg==', + }); + const valid = await provider.webhooks?.verify({ + request: dodoWebhookRequest( + { + business_id: 'biz_123', + type: 'payment.failed', + timestamp: new Date().toISOString(), + data: { + payment_id: 'pay_123', + total_amount: 1200, + currency: 'USD', + customer: { + customer_id: 'cus_123', + email: 'ana@example.com', + name: 'Ana', + }, + metadata: {}, + status: 'failed', + }, + }, + 'whsec_d3Jvbmdfa2V5', + ), + }); + + expect(valid).toBe(false); + }); +}); + +function dodoWebhookRequest(payload: unknown, secret: string) { + const body = JSON.stringify(payload); + const webhookId = 'msg_123'; + const timestamp = String(Math.floor(Date.now() / 1000)); + const signature = signDodoWebhook({ + payload: body, + secret, + timestamp, + webhookId, + }); + + return new Request('https://app.test/webhooks', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'webhook-id': webhookId, + 'webhook-signature': signature, + 'webhook-timestamp': timestamp, + }, + body, + }); +} diff --git a/packages/dodo/tsconfig.json b/packages/dodo/tsconfig.json new file mode 100644 index 0000000..d6b0121 --- /dev/null +++ b/packages/dodo/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "paymesh": ["../paymesh/src/index.ts"] + }, + "types": ["bun"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/dodo/tsdown.config.mjs b/packages/dodo/tsdown.config.mjs new file mode 100644 index 0000000..21f6bf4 --- /dev/null +++ b/packages/dodo/tsdown.config.mjs @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + clean: true, + sourcemap: false, + minify: true, + target: 'node20', + outDir: 'dist', +}); From 531cd6571683ae18bb235416a6e6bd0d8786afa8 Mon Sep 17 00:00:00 2001 From: almeida Date: Sat, 13 Jun 2026 10:53:35 -0300 Subject: [PATCH 03/15] docs: document dodo provider --- README.md | 6 +++++- package.json | 2 +- packages/cli/src/shared/providers.ts | 7 +++++++ packages/paymesh/README.md | 8 ++++++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d240fca..4a70e42 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ npm install paymesh @paymesh/stripe @paymesh/prisma @prisma/client ```

- Available providers currently include @paymesh/stripe and @paymesh/polar. + Available providers currently include @paymesh/stripe, @paymesh/polar, @paymesh/abacatepay, and @paymesh/dodo.

@@ -99,6 +99,10 @@ npm install paymesh @paymesh/stripe @paymesh/prisma @prisma/client Native PIX flows live under paymesh.pix. Use paymesh.payments for generic checkout sessions and paymesh.pix when you need QR code, copia-e-cola, and expiration data as first-class fields.

+

+ @paymesh/dodo supports hosted BRL checkout links that can present Pix inside the Dodo checkout page, but it intentionally does not expose paymesh.pix because Dodo does not currently document a native QR-code-first backend PIX flow. +

+

Database packages: @paymesh/postgres, @paymesh/drizzle, and @paymesh/prisma. Operational tooling: @paymesh/cli.

diff --git a/package.json b/package.json index 0d69c1a..c51e66d 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "check": "biome check .", "check:write": "biome check --write .", "typecheck": "bun run typecheck:packages", - "typecheck:packages": "bun run --filter './packages/{paymesh,audit-logs,memory,postgres,drizzle,prisma,stripe,polar,abacatepay,express,fastify,hono,elysia,next,cli,mcp}' typecheck", + "typecheck:packages": "bun run --filter './packages/{paymesh,audit-logs,memory,postgres,drizzle,prisma,stripe,polar,abacatepay,dodo,express,fastify,hono,elysia,next,cli,mcp}' typecheck", "typecheck:apps": "bun run --filter './apps/*' typecheck", "types": "bun run typecheck", "test": "bun test --pass-with-no-tests", diff --git a/packages/cli/src/shared/providers.ts b/packages/cli/src/shared/providers.ts index 33a05a0..d2f663a 100644 --- a/packages/cli/src/shared/providers.ts +++ b/packages/cli/src/shared/providers.ts @@ -13,6 +13,13 @@ export const PROVIDERS = { webhookSecretEnv: 'POLAR_WEBHOOK_SECRET', paramName: 'accessToken', }, + dodo: { + label: 'Dodo Payments', + package: '@paymesh/dodo', + apiKeyEnv: 'DODO_PAYMENTS_API_KEY', + webhookSecretEnv: 'DODO_PAYMENTS_WEBHOOK_KEY', + paramName: 'apiKey', + }, custom: { label: 'Custom — I will configure later', package: null, diff --git a/packages/paymesh/README.md b/packages/paymesh/README.md index 4371b6e..9ac456c 100644 --- a/packages/paymesh/README.md +++ b/packages/paymesh/README.md @@ -29,6 +29,10 @@ npm install paymesh @paymesh/stripe @paymesh/postgres For tests, CI, and local examples, use @paymesh/memory instead of a production database adapter.

+

+ Official provider packages currently include @paymesh/stripe, @paymesh/polar, @paymesh/abacatepay, and @paymesh/dodo. +

+

Quickstart

@@ -77,6 +81,10 @@ console.log(payment.checkoutUrl, customer.email); For native PIX flows, use paymesh.pix. This is separate from paymesh.payments so providers can expose QR code, copia-e-cola, and expiration details directly.

+

+ @paymesh/dodo supports hosted BRL checkout links that can offer Pix inside the redirect flow, but it intentionally does not expose paymesh.pix because Dodo does not currently document a native QR-code-first backend PIX flow. +

+ ```ts const pix = await paymesh.pix.create({ amount: 3500, From 1ddb86f6d0288863b5105b81f9dad90e5060c12d Mon Sep 17 00:00:00 2001 From: almeida Date: Sat, 13 Jun 2026 11:02:03 -0300 Subject: [PATCH 04/15] chore(dodo): add it in changeset --- .changeset/cyan-poems-boil.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/cyan-poems-boil.md diff --git a/.changeset/cyan-poems-boil.md b/.changeset/cyan-poems-boil.md new file mode 100644 index 0000000..ef19d7e --- /dev/null +++ b/.changeset/cyan-poems-boil.md @@ -0,0 +1,8 @@ +--- +"@paymesh/dodo": minor +"@paymesh/cli": patch +--- + +Ship the first-party `@paymesh/dodo` provider with catalog-driven hosted checkout, customer upsert/get, catalog sync, dashboard sync helpers, and verified webhook normalization for Dodo Payments. + +Update `@paymesh/cli` provider selection so new setups can choose Dodo directly. From 71b2f67ba73fa82720dd37803e88ae92e200a2a1 Mon Sep 17 00:00:00 2001 From: almeida Date: Sat, 13 Jun 2026 11:02:19 -0300 Subject: [PATCH 05/15] fix(dodo): remove the coming-soon tag --- apps/web/src/lib/docs.tsx | 248 ++++++++++++++++++++++++++++++++++---- 1 file changed, 224 insertions(+), 24 deletions(-) diff --git a/apps/web/src/lib/docs.tsx b/apps/web/src/lib/docs.tsx index 3021d09..3ecb46f 100644 --- a/apps/web/src/lib/docs.tsx +++ b/apps/web/src/lib/docs.tsx @@ -429,6 +429,47 @@ export const app = new Elysia().post( }), );`; +const dodoSetupSnippet = `import { createClient } from "paymesh"; +import { dodo } from "@paymesh/dodo"; + +export const client = createClient({ + provider: dodo({ + apiKey: process.env.DODO_PAYMENTS_API_KEY!, + webhookSecret: process.env.DODO_PAYMENTS_WEBHOOK_KEY, + baseUrl: "https://test.dodopayments.com", + }), +});`; + +const dodoPaymentsSnippet = `const payment = await client.payments.create({ + amount: 4900, + currency: "BRL", + productIds: ["prod_abc123"], + customer: { + email: "billing@example.com", + externalId: "order_42", + name: "Billing Team", + }, + successUrl: "https://example.com/success", + cancelUrl: "https://example.com/cancel", + metadata: { + orderId: "order_42", + }, +});`; + +const dodoWebhookSnippet = `import { Elysia } from "elysia"; +import { Webhooks } from "@paymesh/elysia"; + +export const app = new Elysia().post( + "/webhooks/dodo", + Webhooks({ + client, + + async onPaymentSucceeded(event) { + console.log("payment.succeeded", event.id); + }, + }), +);`; + const postgresSnippet = `import { createClient } from "paymesh"; import { postgres } from "@paymesh/postgres"; import { stripe } from "@paymesh/stripe"; @@ -827,8 +868,8 @@ const guidePages: DocPage[] = [ ], [ 'Dodo', - 'Another billing route for teams that want optionality.', - 'Roadmap-only in the current repo build.', + 'Catalog-driven hosted checkout with Dodo Payments.', + 'No native paymesh.pix flow; Pix only appears inside BRL hosted checkout.', ], ]} /> @@ -1904,7 +1945,7 @@ const docsNavigation: DocNavGroup[] = [ status: 'coming-soon', }, { label: 'PayPal', slug: ['providers', 'paypal'], status: 'coming-soon' }, - { label: 'Dodo', slug: ['providers', 'dodo'], status: 'coming-soon' }, + { label: 'Dodo', slug: ['providers', 'dodo'] }, ], }, { @@ -2126,6 +2167,65 @@ const polarCapabilityRows: ReactNode[][] = [ ], ]; +const dodoCapabilityRows: ReactNode[][] = [ + [ + checkout, + + Available + , + 'Creates Dodo hosted payments through catalog-driven product carts.', + ], + [ + customers, + + Available + , + 'Upserts and fetches Dodo customers.', + ], + [ + webhooks, + + Available + , + 'Verifies webhook-id, webhook-timestamp, and webhook-signature headers before dispatching normalized hooks.', + ], + [ + subscriptions, + + Advertised + , + 'Subscription webhook events and dashboard sync are implemented.', + ], + [ + pix, + + False + , + 'Dodo can offer hosted Pix inside BRL checkout links, but it does not expose native paymesh.pix flows.', + ], + [ + refunds, + + False + , + 'Refund webhooks are normalized, but the provider does not expose refund helpers as a public capability.', + ], + [ + customerPortal, + + False + , + 'No dedicated customer portal helper is exposed by the package.', + ], + [ + coupons, + + False + , + 'No coupon capability is exposed by the package.', + ], +]; + const polarEventRows: ReactNode[][] = [ ['checkout.created', 'payment.created', 'onPaymentCreated'], ['checkout.updated', 'checkout.completed', 'onCheckoutCompleted'], @@ -2275,6 +2375,10 @@ const docsPages: DocPage[] = [ @paymesh/polar, 'You are using Polar as the upstream merchant-of-record provider.', ], + [ + @paymesh/dodo, + 'You are using Dodo Payments for catalog-driven hosted checkout flows.', + ], [ @paymesh/next, 'Your webhook endpoint runs in a Next.js App Router route.', @@ -3277,10 +3381,10 @@ rawPayment.raw; // provider payload`} ], [ 'Dodo', - - Coming Soon + + Available , - 'Planned additional modern billing route.', + 'Shipped catalog-driven provider with hosted checkout, customers, webhooks, catalog sync, and subscription sync.', ], ]} /> @@ -3702,6 +3806,120 @@ rawPayment.raw; // provider payload`} }, ], }, + { + group: 'Providers', + slug: ['providers', 'dodo'], + title: 'Dodo', + description: + 'Dodo is a shipped Paymesh provider for catalog-driven hosted checkout, customer operations, catalog sync, and normalized webhook handling.', + sections: [ + { + id: 'setup', + title: 'Setup', + content: ( +
+ + The Dodo provider lives in @paymesh/dodo{' '} + and reads DODO_PAYMENTS_API_KEY and + optional DODO_PAYMENTS_WEBHOOK_KEY by + default. + + +
+ ), + }, + { + id: 'capabilities', + title: 'Capabilities', + content: ( +
+ +
+ ), + }, + { + id: 'payments', + title: 'Hosted Checkout Flow', + content: ( +
+ + Dodo payment creation is catalog-driven. You must provide at least + one productIds entry, and{' '} + amount is only accepted when exactly one + product id is present. + + + + For BRL hosted checkout links, the provider enables Dodo payment + methods that can include Pix inside the hosted checkout page. This + does not expose the dedicated client.pix{' '} + surface. + +
+ ), + }, + { + id: 'customers', + title: 'Customer Operations', + content: ( +
+ + The Dodo provider supports normalized{' '} + upsert and get{' '} + for customers. Delete is intentionally unsupported because the + package does not expose a provider-side customer delete endpoint. + + +
+ ), + }, + { + id: 'webhooks', + title: 'Webhook Mapping', + content: ( +
+ + Dodo webhook verification reconstructs the standard signed message + using webhook-id,{' '} + webhook-timestamp, and the raw request + body before comparing the expected HMAC-SHA256 signature with the + v1 signature. + + + +
+ ), + }, + ], + }, ...[ { slug: ['providers', 'abacatepay'], @@ -3739,24 +3957,6 @@ rawPayment.raw; // provider payload`} ['refunds', 'planned'], ] as const, }, - { - slug: ['providers', 'dodo'], - title: 'Dodo', - packageName: '@paymesh/dodo', - notes: - 'Planned additional modern billing route for teams that want optionality beyond the first provider.', - targets: [ - 'Consistent payment and customer primitives through the same core client.', - 'Webhook normalization into Paymesh event hooks.', - 'Provider capability modeling that matches the rest of the ecosystem.', - ], - capabilities: [ - ['checkout', 'planned'], - ['customers', 'planned'], - ['webhooks', 'planned'], - ['subscriptions', 'planned'], - ] as const, - }, ].map((provider) => ({ group: 'Providers', slug: provider.slug, From f1f0749f4d7e9d8d68297e411be61cd447c1f993 Mon Sep 17 00:00:00 2001 From: almeida Date: Sat, 13 Jun 2026 11:04:02 -0300 Subject: [PATCH 06/15] chore: update dependencies --- bun.lock | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bun.lock b/bun.lock index 743b8d3..b791dc1 100644 --- a/bun.lock +++ b/bun.lock @@ -89,6 +89,16 @@ "paymesh": ">=0.0.0", }, }, + "packages/dodo": { + "name": "@paymesh/dodo", + "version": "0.0.0", + "devDependencies": { + "paymesh": "workspace:*", + }, + "peerDependencies": { + "paymesh": ">=0.0.0", + }, + }, "packages/drizzle": { "name": "@paymesh/drizzle", "version": "0.0.0", @@ -514,6 +524,8 @@ "@paymesh/dash": ["@paymesh/dash@workspace:packages/dash"], + "@paymesh/dodo": ["@paymesh/dodo@workspace:packages/dodo"], + "@paymesh/drizzle": ["@paymesh/drizzle@workspace:packages/drizzle"], "@paymesh/elysia": ["@paymesh/elysia@workspace:packages/elysia"], From 0f5de20f7cd82aecf8f0cf7ca83b83625fe9c92e Mon Sep 17 00:00:00 2001 From: almeida Date: Sat, 13 Jun 2026 12:09:10 -0300 Subject: [PATCH 07/15] feat(paymesh): extend types and database repositories to support coupons --- packages/paymesh/src/client/coupons.ts | 215 +++++++++++++++++++++ packages/paymesh/src/client/managers.ts | 9 + packages/paymesh/src/database/schema.ts | 1 + packages/paymesh/src/types/client.ts | 83 ++++++++ packages/paymesh/src/types/database.ts | 90 +++++++++ packages/paymesh/src/types/providers.ts | 247 ++++++++++++++++++++++++ packages/paymesh/test/client.test.ts | 153 +++++++++++++++ 7 files changed, 798 insertions(+) create mode 100644 packages/paymesh/src/client/coupons.ts diff --git a/packages/paymesh/src/client/coupons.ts b/packages/paymesh/src/client/coupons.ts new file mode 100644 index 0000000..84a6c9b --- /dev/null +++ b/packages/paymesh/src/client/coupons.ts @@ -0,0 +1,215 @@ +import { splitExtraFields } from '../shared/database/fields'; +import type { + PaymeshCoupon, + PaymeshCouponCheckResult, + PaymeshCouponCreateData, + PaymeshCouponList, + PaymeshCouponUpdateData, +} from '../types/client'; +import type { + DatabaseSchemaOptions, + PaymeshCouponListOptions, + PaymeshDatabaseDriver, + ResolvedDatabaseSchema, +} from '../types/database'; +import type { + CouponCheckData, + Provider, + ProviderRequestOptions, +} from '../types/providers'; +import { getRequiredProviderFeature, resolveIncludeRaw } from './helpers'; + +export function createCouponsClient< + Schema extends DatabaseSchemaOptions, + P extends Provider, + IncludeRaw extends boolean, +>({ + assertCapability, + baseIncludeRaw, + database, + mergeOptions, + provider, + schema, +}: { + assertCapability: (capability: 'coupons') => void; + baseIncludeRaw: boolean; + database?: PaymeshDatabaseDriver; + mergeOptions: ( + requestOptions?: ProviderRequestOptions, + ) => ProviderRequestOptions; + provider: P; + schema: ResolvedDatabaseSchema; +}) { + const couponsRepository = database?.repositories.coupons; + const getProviderCoupons = () => + getRequiredProviderFeature(provider.coupons, provider.id, 'coupons'); + + return { + create: async ( + data: PaymeshCouponCreateData, + requestOptions?: ProviderRequestOptions, + ) => { + assertCapability('coupons'); + const providerCoupons = getProviderCoupons(); + + const { input, extra } = splitExtraFields( + data, + schema.tables.coupons.fields, + ); + const coupon = await providerCoupons.create( + input as Parameters[0], + mergeOptions(requestOptions), + ); + const resolvedCoupon = Object.assign(coupon, extra) as PaymeshCoupon< + CallIncludeRaw, + Schema + >; + + if (couponsRepository) { + await couponsRepository.upsert(schema, resolvedCoupon); + } + + return resolvedCoupon; + }, + get: async ( + id: string, + requestOptions?: ProviderRequestOptions, + ) => { + assertCapability('coupons'); + const providerCoupons = getProviderCoupons(); + const mergedOptions = mergeOptions(requestOptions); + + if (couponsRepository) { + const coupon = await couponsRepository.findByProviderId( + schema, + provider.id, + mergedOptions.sandbox ?? provider.isSandbox(), + id, + { + includeRaw: mergedOptions.includeRaw, + }, + ); + + if (coupon) { + return coupon as PaymeshCoupon; + } + } + + return providerCoupons.get(id, mergedOptions) as Promise< + PaymeshCoupon + >; + }, + list: async ( + options?: PaymeshCouponListOptions, + ) => { + assertCapability('coupons'); + const providerCoupons = getProviderCoupons(); + + if (couponsRepository) { + const result = await couponsRepository.list( + schema, + provider.id, + options?.sandbox ?? provider.isSandbox(), + { + includeRaw: resolveIncludeRaw( + options?.includeRaw, + baseIncludeRaw, + ) as CallIncludeRaw, + limit: options?.limit, + after: options?.after, + before: options?.before, + code: options?.code, + active: options?.active, + }, + ); + + return { + ...result, + data: result.data as Array>, + } as PaymeshCouponList; + } + + return providerCoupons.list( + { + limit: options?.limit, + after: options?.after, + before: options?.before, + code: options?.code, + active: options?.active, + }, + mergeOptions({ + includeRaw: resolveIncludeRaw( + options?.includeRaw, + baseIncludeRaw, + ) as CallIncludeRaw, + sandbox: options?.sandbox, + }), + ) as Promise>; + }, + update: async ( + id: string, + data: PaymeshCouponUpdateData, + requestOptions?: ProviderRequestOptions, + ) => { + assertCapability('coupons'); + const providerCoupons = getProviderCoupons(); + + const { input, extra } = splitExtraFields( + data, + schema.tables.coupons.fields, + ); + const coupon = await providerCoupons.update( + id, + input as Parameters[1], + mergeOptions(requestOptions), + ); + const resolvedCoupon = Object.assign(coupon, extra) as PaymeshCoupon< + CallIncludeRaw, + Schema + >; + + if (couponsRepository) { + await couponsRepository.upsert(schema, resolvedCoupon); + } + + return resolvedCoupon; + }, + delete: async ( + id: string, + requestOptions?: ProviderRequestOptions, + ) => { + assertCapability('coupons'); + const providerCoupons = getProviderCoupons(); + const result = await providerCoupons.delete( + id, + mergeOptions(requestOptions), + ); + + if (couponsRepository) { + await couponsRepository.markDeleted(schema, result); + } + + return result; + }, + check: async ( + data: CouponCheckData, + requestOptions?: ProviderRequestOptions, + ) => { + assertCapability('coupons'); + const providerCoupons = getProviderCoupons(); + const result = await providerCoupons.check( + data, + mergeOptions(requestOptions), + ); + + if (couponsRepository && result.coupon) { + await couponsRepository.upsert( + schema, + result.coupon as PaymeshCoupon, + ); + } + + return result as PaymeshCouponCheckResult; + }, + }; +} diff --git a/packages/paymesh/src/client/managers.ts b/packages/paymesh/src/client/managers.ts index 285a3fe..2807180 100644 --- a/packages/paymesh/src/client/managers.ts +++ b/packages/paymesh/src/client/managers.ts @@ -13,6 +13,7 @@ import type { } from '../types/database'; import type { AnyPaymeshPlugin } from '../types/plugins'; import type { Provider, ProviderCapability } from '../types/providers'; +import { createCouponsClient } from './coupons'; import { createCustomersClient } from './customers'; import type { RuntimeHookDispatcher } from './helpers'; import { createPaymentsClient } from './payments'; @@ -104,6 +105,14 @@ export function createClientManagers< provider, schema, }), + coupons: createCouponsClient({ + assertCapability, + baseIncludeRaw, + database, + mergeOptions, + provider, + schema, + }), webhooks: createWebhookClient({ baseIncludeRaw, database, diff --git a/packages/paymesh/src/database/schema.ts b/packages/paymesh/src/database/schema.ts index 4afe2c7..0ed686e 100644 --- a/packages/paymesh/src/database/schema.ts +++ b/packages/paymesh/src/database/schema.ts @@ -22,6 +22,7 @@ export const PAYMESH_DEFAULT_SCHEMA_PREFIX = 'paymesh_'; */ export const PAYMESH_DATABASE_TABLE_NAMES = { customers: 'customers', + coupons: 'coupons', pix: 'pix', checkouts: 'checkouts', invoices: 'invoices', diff --git a/packages/paymesh/src/types/client.ts b/packages/paymesh/src/types/client.ts index 2092ada..53e9398 100644 --- a/packages/paymesh/src/types/client.ts +++ b/packages/paymesh/src/types/client.ts @@ -3,6 +3,7 @@ import type { DatabaseSchemaOptions, DatabaseTableInputExtraFields, DatabaseTableOutputExtraFields, + PaymeshCouponListOptions, PaymeshCustomerListOptions, PaymeshCustomerListResult, PaymeshDatabaseDriver, @@ -19,6 +20,13 @@ import type { } from './plugins'; import type { AnyPayment, + Coupon, + CouponCheckData, + CouponCheckResult, + CouponCreateData, + CouponDeleteResult, + CouponListResult, + CouponUpdateData, Customer, CustomerDeleteResult, CustomerUpsertData, @@ -283,6 +291,40 @@ export type PaymeshCustomerList< PaymeshCustomerListResult> >; +/** Paymesh coupon input with schema-derived extra fields. */ +export type PaymeshCouponCreateData< + Schema extends DatabaseSchemaOptions = DatabaseSchemaOptions, +> = Simplify< + CouponCreateData & DatabaseTableInputExtraFields +>; + +/** Paymesh coupon update input with schema-derived extra fields. */ +export type PaymeshCouponUpdateData< + Schema extends DatabaseSchemaOptions = DatabaseSchemaOptions, +> = Simplify< + CouponUpdateData & DatabaseTableInputExtraFields +>; + +/** Paymesh coupon output with schema-derived extra fields. */ +export type PaymeshCoupon< + IncludeRaw extends boolean = false, + Schema extends DatabaseSchemaOptions = DatabaseSchemaOptions, +> = Simplify< + Coupon & DatabaseTableOutputExtraFields +>; + +/** Paginated coupon list using the Paymesh coupon shape. */ +export type PaymeshCouponList< + IncludeRaw extends boolean = false, + Schema extends DatabaseSchemaOptions = DatabaseSchemaOptions, +> = Simplify>>; + +/** Coupon validation result using the Paymesh coupon shape. */ +export type PaymeshCouponCheckResult< + IncludeRaw extends boolean = false, + Schema extends DatabaseSchemaOptions = DatabaseSchemaOptions, +> = CouponCheckResult>; + /** Payments sub-client exposed by `createClient`. */ export interface PaymeshPaymentsClient< IncludeRaw extends boolean = false, @@ -338,6 +380,45 @@ export interface PaymeshCustomersClient< ): Promise>; } +/** Coupons sub-client exposed by `createClient`. */ +export interface PaymeshCouponsClient< + IncludeRaw extends boolean = false, + Schema extends DatabaseSchemaOptions = DatabaseSchemaOptions, +> { + /** Creates a coupon through the configured provider. */ + create( + data: PaymeshCouponCreateData, + options?: ProviderRequestOptions, + ): Promise>; + /** Loads a coupon from the database or provider. */ + get( + id: string, + options?: ProviderRequestOptions, + ): Promise>; + /** Lists coupons for the configured provider. */ + list( + options?: PaymeshCouponListOptions, + ): Promise>; + /** Updates a coupon through the configured provider. */ + update( + id: string, + data: PaymeshCouponUpdateData, + options?: ProviderRequestOptions, + ): Promise>; + /** Deletes a coupon through the configured provider. */ + delete( + id: string, + options?: ProviderRequestOptions, + ): Promise>; + /** Validates a coupon code against the provided cart context. */ + check( + data: CouponCheckData, + options?: ProviderRequestOptions, + ): Promise< + CouponCheckResult> + >; +} + /** Full Paymesh client returned by `createClient`. */ export interface PaymeshClient< IncludeRaw extends boolean = false, @@ -362,6 +443,8 @@ export interface PaymeshClient< pix: PaymeshPixClient; /** Customers sub-client. */ customers: PaymeshCustomersClient; + /** Coupons sub-client. */ + coupons: PaymeshCouponsClient; /** Webhook handler contract. */ webhooks: PaymeshWebhookHandler; /** Plugin route dispatcher. */ diff --git a/packages/paymesh/src/types/database.ts b/packages/paymesh/src/types/database.ts index 576aebe..8672f62 100644 --- a/packages/paymesh/src/types/database.ts +++ b/packages/paymesh/src/types/database.ts @@ -1,11 +1,14 @@ import type { AnyPayment, BaseAnyPayment, + BaseCoupon, + BaseCouponDeleteResult, BaseCustomer, BaseCustomerDeleteResult, BasePayment, BasePaymeshEvent, BasePix, + Coupon, Customer, Pix, ProviderCatalogPrice, @@ -17,6 +20,7 @@ import type { */ export type DatabaseTableKey = | 'customers' + | 'coupons' | 'pix' | 'checkouts' | 'invoices' @@ -421,6 +425,43 @@ export interface PaymeshCustomerListResult< next: string | null; } +/** + * Cursor and paging options for coupon lists. + */ +export interface PaymeshCouponListOptions { + /** Page size. */ + limit?: number; + /** Cursor for results after the given page. */ + after?: string; + /** Cursor for results before the given page. */ + before?: string; + /** Filter by exact coupon code. */ + code?: string; + /** Filter by active flag. */ + active?: boolean; + /** Include raw payloads in returned values. Defaults to `false`. */ + includeRaw?: IncludeRaw; + /** Overrides the sandbox mode for this query. Defaults to the provider sandbox mode. */ + sandbox?: boolean; +} + +/** + * Paginated coupon list response. + */ +export interface PaymeshCouponListResult< + IncludeRaw extends boolean = false, + TCoupon = Coupon, +> { + /** Page items. */ + data: TCoupon[]; + /** Total matching count. */ + total: number; + /** Cursor for the previous page. */ + previous: string | null; + /** Cursor for the next page. */ + next: string | null; +} + /** * Repository interface for customer persistence. */ @@ -480,6 +521,54 @@ export interface PaymeshCustomersRepository { ): Promise; } +/** + * Repository interface for coupon persistence. + */ +export interface PaymeshCouponsRepository { + /** Finds a coupon by provider id. */ + findByProviderId< + IncludeRaw extends boolean = false, + TCoupon extends Coupon = Coupon, + >( + schema: ResolvedDatabaseSchema, + provider: string, + sandbox: boolean, + id: string, + options?: PaymeshRepositoryReadOptions, + ): Promise; + /** Finds a coupon by code. */ + findByCode?< + IncludeRaw extends boolean = false, + TCoupon extends Coupon = Coupon, + >( + schema: ResolvedDatabaseSchema, + provider: string, + sandbox: boolean, + code: string, + options?: PaymeshRepositoryReadOptions, + ): Promise; + /** Inserts or updates a coupon row. */ + upsert( + schema: ResolvedDatabaseSchema, + coupon: TCoupon, + ): Promise; + /** Lists coupons for a provider. */ + list< + IncludeRaw extends boolean = false, + TCoupon extends Coupon = Coupon, + >( + schema: ResolvedDatabaseSchema, + provider: string, + sandbox: boolean, + options?: PaymeshCouponListOptions, + ): Promise>; + /** Marks a coupon as deleted. */ + markDeleted( + schema: ResolvedDatabaseSchema, + coupon: BaseCouponDeleteResult, + ): Promise; +} + /** * Repository interface for checkout persistence. */ @@ -627,6 +716,7 @@ export interface PaymeshMigrationsRepository { */ export interface PaymeshDatabaseRepositories { customers: PaymeshCustomersRepository; + coupons?: PaymeshCouponsRepository; pix: PaymeshPixRepository; checkouts: PaymeshCheckoutsRepository; invoices: PaymeshInvoicesRepository; diff --git a/packages/paymesh/src/types/providers.ts b/packages/paymesh/src/types/providers.ts index 4c08798..0c16fea 100644 --- a/packages/paymesh/src/types/providers.ts +++ b/packages/paymesh/src/types/providers.ts @@ -62,6 +62,10 @@ export interface PaymentCreateData { currency?: string; /** Provider-specific product ids attached to the payment. Required by providers that price via catalog (e.g. AbacatePay). */ productIds?: string[]; + /** Coupon or promotion code to pre-apply on the checkout. */ + couponCode?: string; + /** Whether the hosted checkout should allow customers to enter coupon codes manually. */ + allowCouponCodes?: boolean; /** Optional customer context for the payment. */ customer?: PaymentCustomer; @@ -205,6 +209,212 @@ export type CustomerDeleteResult = WithRaw< IncludeRaw >; +/** Common coupon status values normalized across providers. */ +export type CouponStatus = + | 'active' + | 'inactive' + | 'scheduled' + | 'expired' + | 'deleted'; + +/** Normalized coupon discount type. */ +export type CouponDiscountType = 'percentage' | 'fixed'; + +/** Percentage-based discount definition. */ +export interface CouponPercentageDiscount { + type: 'percentage'; + value: number; +} + +/** Fixed-amount discount definition. */ +export interface CouponFixedDiscount { + type: 'fixed'; + value: number; + currency?: string; + amounts?: Record; +} + +/** Normalized discount definition accepted and returned by the coupons API. */ +export type CouponDiscount = CouponPercentageDiscount | CouponFixedDiscount; + +/** Duration modes supported by provider-side coupons. */ +export interface CouponDuration { + type: 'once' | 'forever' | 'repeating'; + durationInMonths?: number; +} + +/** Product or price restrictions applied to a coupon. */ +export interface CouponAppliesTo { + products?: string[]; + prices?: string[]; +} + +/** Redemption counters exposed by providers. */ +export interface CouponRedemptions { + count: number; + max: number | null; +} + +/** Normalized coupon payload returned by providers. */ +export interface BaseCoupon { + id: string; + provider: string; + sandbox: boolean; + code: string; + name?: string; + status: CouponStatus; + active?: boolean; + discount: CouponDiscount; + duration?: CouponDuration; + startsAt?: string; + expiresAt?: string; + customerId?: string; + firstTimeOnly?: boolean; + minimumAmount?: number; + minimumAmountCurrency?: string; + appliesTo?: CouponAppliesTo; + redemptions: CouponRedemptions; + metadata?: Record; +} + +/** Coupon payload that optionally carries the raw provider response. */ +export type Coupon = WithRaw< + BaseCoupon, + IncludeRaw +>; + +/** Input used to create a coupon or promotion code. */ +export interface CouponCreateData { + code: string; + name?: string; + discount: CouponDiscount; + duration?: CouponDuration; + startsAt?: Date | string; + expiresAt?: Date | string; + maxRedemptions?: number | null; + customerId?: string; + firstTimeOnly?: boolean; + minimumAmount?: number; + minimumAmountCurrency?: string; + appliesTo?: CouponAppliesTo; + metadata?: Record; +} + +/** Input used to update a coupon or promotion code. */ +export interface CouponUpdateData { + code?: string; + name?: string; + active?: boolean; + discount?: CouponDiscount; + duration?: CouponDuration; + startsAt?: Date | string | null; + expiresAt?: Date | string | null; + maxRedemptions?: number | null; + customerId?: string | null; + firstTimeOnly?: boolean; + minimumAmount?: number | null; + minimumAmountCurrency?: string | null; + appliesTo?: CouponAppliesTo; + metadata?: Record; +} + +/** Cursor-based filters supported when listing coupons. */ +export interface CouponListOptions { + limit?: number; + after?: string; + before?: string; + code?: string; + active?: boolean; + status?: CouponStatus; +} + +/** Paginated coupon list response. */ +export interface CouponListResult< + IncludeRaw extends boolean = false, + TCoupon = Coupon, +> { + data: TCoupon[]; + total: number; + previous: string | null; + next: string | null; +} + +/** Returned after deleting a coupon or promotion code. */ +export interface BaseCouponDeleteResult { + id: string; + provider: string; + sandbox: boolean; + deleted: boolean; + code?: string; +} + +/** Coupon delete payload that optionally carries the raw provider response. */ +export type CouponDeleteResult = WithRaw< + BaseCouponDeleteResult, + IncludeRaw +>; + +/** Reasons returned when coupon validation fails. */ +export type CouponInvalidReason = + | 'not_found' + | 'inactive' + | 'expired' + | 'not_started' + | 'max_redemptions_reached' + | 'customer_not_eligible' + | 'product_not_eligible' + | 'currency_not_supported' + | 'minimum_amount_not_reached' + | 'provider_error'; + +/** Price preview returned when checking a coupon against a cart. */ +export interface CouponCheckPreview { + subtotal: number; + discountTotal: number; + total: number; + currency: string; +} + +/** Input accepted by `coupons.check()`. */ +export interface CouponCheckData { + code: string; + customerId?: string; + productIds?: string[]; + priceIds?: string[]; + amount?: number; + currency?: string; +} + +/** Successful coupon validation result. */ +export interface CouponCheckValidResult< + IncludeRaw extends boolean = false, + TCoupon = Coupon, +> { + valid: true; + coupon: TCoupon; + preview?: CouponCheckPreview; +} + +/** Failed coupon validation result. */ +export interface CouponCheckInvalidResult< + IncludeRaw extends boolean = false, + TCoupon = Coupon, +> { + valid: false; + reason: CouponInvalidReason; + message?: string; + coupon?: TCoupon; + preview?: CouponCheckPreview; +} + +/** Coupon validation result union. */ +export type CouponCheckResult< + IncludeRaw extends boolean = false, + TCoupon = Coupon, +> = + | CouponCheckValidResult + | CouponCheckInvalidResult; + /** Request-level overrides passed to provider methods. */ export interface ProviderRequestOptions { /** Overrides the provider base URL. */ @@ -360,6 +570,41 @@ export interface ProviderCustomers { ): Promise>; } +/** Coupon management contract. */ +export interface ProviderCoupons { + /** Loads a coupon by id. */ + get( + id: string, + options?: ProviderRequestOptions, + ): Promise>; + /** Creates a coupon or promotion code. */ + create( + data: CouponCreateData, + options?: ProviderRequestOptions, + ): Promise>; + /** Lists coupons exposed by the provider. */ + list( + listOptions?: CouponListOptions, + options?: ProviderRequestOptions, + ): Promise>; + /** Updates a coupon or promotion code. */ + update( + id: string, + data: CouponUpdateData, + options?: ProviderRequestOptions, + ): Promise>; + /** Deletes a coupon or promotion code. */ + delete( + id: string, + options?: ProviderRequestOptions, + ): Promise>; + /** Validates a coupon against the current cart or amount context. */ + check( + data: CouponCheckData, + options?: ProviderRequestOptions, + ): Promise>; +} + /** Context passed to webhook verification. */ export interface ProviderVerifyWebhookContext { request: Request; @@ -456,6 +701,8 @@ export interface ProviderDefinition { /** Customer management contract. */ customers: ProviderCustomers; + /** Optional coupon management contract. */ + coupons?: ProviderCoupons; /** Optional webhook contract. */ webhooks?: ProviderWebhooks; diff --git a/packages/paymesh/test/client.test.ts b/packages/paymesh/test/client.test.ts index 7a4d9bb..1e26276 100644 --- a/packages/paymesh/test/client.test.ts +++ b/packages/paymesh/test/client.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from 'bun:test'; import { + type Coupon, + type CouponCheckData, + type CouponCheckResult, + type CouponCreateData, type Customer, createClient, defineDatabaseAdapter, @@ -156,6 +160,35 @@ describe('client', () => { expect(defaultPix.method).toBe('pix'); }); + test('exposes coupons client methods and raw payload controls', async () => { + const provider = createStubProvider(); + const defaultClient = createClient({ provider }); + const rawClient = createClient({ provider, includeRaw: true }); + + const defaultCoupon = await defaultClient.coupons.create({ + code: 'WELCOME10', + discount: { type: 'percentage', value: 10 }, + }); + const rawCoupon = await rawClient.coupons.get('promo_123'); + const checkResult = await defaultClient.coupons.check({ + code: 'WELCOME10', + amount: 1000, + currency: 'USD', + }); + + expect(defaultCoupon.raw).toBeNull(); + expect(rawCoupon.raw).toMatchObject({ id: 'raw_coupon_123' }); + expect(checkResult).toMatchObject({ + valid: true, + preview: { + subtotal: 1000, + discountTotal: 100, + total: 900, + currency: 'USD', + }, + }); + }); + test('delegates client.isSandbox() to the provider', () => { const client = createClient({ provider: createStubProvider({ sandbox: true }), @@ -1510,6 +1543,9 @@ function createStubProvider({ onPixCreate, onPixGet, onCustomerUpsert, + onCouponCreate, + onCouponGet, + onCouponCheck, }: { sandbox?: boolean; onPaymentCreate?: ( @@ -1537,12 +1573,25 @@ function createStubProvider({ data: Record, options?: ProviderRequestOptions, ) => Promise>; + onCouponCreate?: ( + data: CouponCreateData, + options?: ProviderRequestOptions, + ) => Promise>; + onCouponGet?: ( + id: string, + options?: ProviderRequestOptions, + ) => Promise>; + onCouponCheck?: ( + data: CouponCheckData, + options?: ProviderRequestOptions, + ) => Promise>; } = {}) { return defineProvider({ id: 'stub', isSandbox: () => sandbox, capabilities: { checkout: true, + coupons: true, customers: true, pix: true, }, @@ -1565,6 +1614,110 @@ function createStubProvider({ options?.includeRaw, ), }, + coupons: { + create: onCouponCreate + ? onCouponCreate + : async (_data, options) => + withRaw( + { + id: 'promo_123', + provider: 'stub', + sandbox, + code: 'WELCOME10', + status: 'active' as const, + discount: { type: 'percentage' as const, value: 10 }, + redemptions: { count: 0, max: null }, + }, + { id: 'raw_coupon_123' }, + options?.includeRaw, + ), + get: onCouponGet + ? onCouponGet + : async (_id, options) => + withRaw( + { + id: 'promo_123', + provider: 'stub', + sandbox, + code: 'WELCOME10', + status: 'active' as const, + discount: { type: 'percentage' as const, value: 10 }, + redemptions: { count: 0, max: null }, + }, + { id: 'raw_coupon_123' }, + options?.includeRaw, + ), + list: async (_options, options) => ({ + data: [ + withRaw( + { + id: 'promo_123', + provider: 'stub', + sandbox, + code: 'WELCOME10', + status: 'active' as const, + discount: { type: 'percentage' as const, value: 10 }, + redemptions: { count: 0, max: null }, + }, + { id: 'raw_coupon_123' }, + options?.includeRaw, + ), + ], + total: 1, + previous: null, + next: null, + }), + update: async (_id, _data, options) => + withRaw( + { + id: 'promo_123', + provider: 'stub', + sandbox, + code: 'WELCOME10', + status: 'active' as const, + discount: { type: 'percentage' as const, value: 10 }, + redemptions: { count: 0, max: null }, + }, + { id: 'raw_coupon_123' }, + options?.includeRaw, + ), + delete: async (_id, options) => + withRaw( + { + id: 'promo_123', + provider: 'stub', + sandbox, + deleted: true, + code: 'WELCOME10', + }, + { id: 'raw_coupon_123' }, + options?.includeRaw, + ), + check: onCouponCheck + ? onCouponCheck + : async (_data, options) => ({ + valid: true, + coupon: withRaw( + { + id: 'promo_123', + provider: 'stub', + sandbox, + code: 'WELCOME10', + status: 'active' as const, + discount: { type: 'percentage' as const, value: 10 }, + redemptions: { count: 0, max: null }, + }, + { id: 'raw_coupon_123' }, + options?.includeRaw, + ), + preview: { + subtotal: 1000, + discountTotal: 100, + total: 900, + currency: 'USD', + }, + }), + }, pix: { create: onPixCreate ? onPixCreate From cb39d3e6fe9fd39b1493b58aeede5da92d7e5a27 Mon Sep 17 00:00:00 2001 From: almeida Date: Sat, 13 Jun 2026 12:09:30 -0300 Subject: [PATCH 08/15] feat(cli): support coupons migration --- packages/cli/src/lib/migrations.ts | 57 ++++++++++++++++++++++++++++++ packages/cli/test/cli.test.ts | 2 +- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/lib/migrations.ts b/packages/cli/src/lib/migrations.ts index cd7d7a5..d448850 100644 --- a/packages/cli/src/lib/migrations.ts +++ b/packages/cli/src/lib/migrations.ts @@ -69,6 +69,7 @@ const PAYMESH_MIGRATIONS: readonly PaymeshMigrationDefinition[] = [ const BUILT_IN_SANDBOX_TABLES: readonly DatabaseTableKey[] = [ 'customers', + 'coupons', 'pix', 'checkouts', 'invoices', @@ -359,6 +360,7 @@ function createInitialMigrationSql(schema: ResolvedDatabaseSchema) { return [ createMigrationsTableSql(schema), createCustomersTableSql(schema), + createCouponsTableSql(schema), createPixTableSql(schema), createCheckoutsTableSql(schema), createInvoicesTableSql(schema), @@ -487,6 +489,10 @@ function createIndexesAndConstraintsSql(schema: ResolvedDatabaseSchema) { createIndexSql(schema, 'customers', ['provider', 'external_id']), createIndexSql(schema, 'customers', ['provider', 'email']), createIndexSql(schema, 'customers', ['provider', 'deleted_at']), + createIndexSql(schema, 'coupons', ['provider', 'code']), + createIndexSql(schema, 'coupons', ['provider', 'status']), + createIndexSql(schema, 'coupons', ['provider', 'expires_at']), + createIndexSql(schema, 'coupons', ['provider', 'deleted_at']), createIndexSql(schema, 'pix', ['provider', 'customer_provider_id']), createIndexSql(schema, 'pix', ['provider', 'status']), createIndexSql(schema, 'pix', ['provider', 'expires_at']), @@ -548,6 +554,24 @@ function createIndexesAndConstraintsSql(schema: ResolvedDatabaseSchema) { 'attempts_valid', 'attempts >= 1', ), + createCheckConstraintSql( + schema, + 'coupons', + 'discount_value_valid', + 'discount_value >= 0', + ), + createCheckConstraintSql( + schema, + 'coupons', + 'redemption_count_valid', + 'redemption_count >= 0', + ), + createCheckConstraintSql( + schema, + 'coupons', + 'max_redemptions_valid', + 'max_redemptions IS NULL OR max_redemptions >= 0', + ), createCheckConstraintSql( schema, 'pix', @@ -629,6 +653,39 @@ ${extraTableColumnsSql(schema, 'customers')} );`.trim(); } +function createCouponsTableSql(schema: ResolvedDatabaseSchema) { + return ` +CREATE TABLE IF NOT EXISTS ${table(schema, 'coupons')} ( + id BIGSERIAL PRIMARY KEY, + provider TEXT NOT NULL, + provider_id TEXT NOT NULL, + version TEXT NOT NULL DEFAULT 'v1', + sandbox BOOLEAN NOT NULL DEFAULT FALSE, + code TEXT NOT NULL, + name TEXT, + status TEXT, + active BOOLEAN, + customer_provider_id TEXT, + discount_type TEXT NOT NULL, + discount_value NUMERIC NOT NULL, + discount_currency TEXT, + starts_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + max_redemptions INTEGER, + redemption_count INTEGER NOT NULL DEFAULT 0, + minimum_amount BIGINT, + minimum_amount_currency TEXT, + metadata JSONB, + data JSONB NOT NULL DEFAULT '{}'::jsonb, + raw JSONB, + deleted_at TIMESTAMPTZ, +${extraTableColumnsSql(schema, 'coupons')} + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (provider, sandbox, provider_id) +);`.trim(); +} + function createPixTableSql(schema: ResolvedDatabaseSchema) { return ` CREATE TABLE IF NOT EXISTS ${table(schema, 'pix')} ( diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 733e07a..6a21ca1 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -913,7 +913,7 @@ describe('cli helpers', () => { const doBlocks = (sandboxMigration?.sql.match(/DO \$\$/g) ?? []).length; // 11 built-in sandbox tables: customers, pix, checkouts, invoices, // paymentMethods, entitlements, usage, webhookEvents, subscriptions, products, prices - expect(doBlocks).toBe(11); + expect(doBlocks).toBe(12); }); test('schema sync migration includes sandbox column and constraint update', async () => { From ed7fdace03453774d6d9130fe554990c5e0cd111 Mon Sep 17 00:00:00 2001 From: almeida Date: Sat, 13 Jun 2026 12:10:41 -0300 Subject: [PATCH 09/15] feat(adapters): implement new coupon repository --- packages/drizzle/src/repositories.ts | 338 ++++++++++++++++++++++++++ packages/memory/src/repositories.ts | 186 ++++++++++++++ packages/memory/src/state.ts | 84 +++++++ packages/memory/src/types.ts | 12 + packages/postgres/src/repositories.ts | 338 ++++++++++++++++++++++++++ packages/prisma/src/repositories.ts | 338 ++++++++++++++++++++++++++ 6 files changed, 1296 insertions(+) diff --git a/packages/drizzle/src/repositories.ts b/packages/drizzle/src/repositories.ts index 6b371e6..f75de5e 100644 --- a/packages/drizzle/src/repositories.ts +++ b/packages/drizzle/src/repositories.ts @@ -279,6 +279,344 @@ export function createRepositories( ], }), }, + coupons: { + async findByProviderId(schema, provider, sandbox, id, options) { + const fields = Object.values(schema.tables.coupons.fields); + const [row] = await executor.query< + { + active: boolean | null; + code: string; + customer_provider_id: string | null; + data: Record | null; + deleted_at: Date | string | null; + discount_currency: string | null; + discount_type: string | null; + discount_value: number | string | null; + expires_at: Date | string | null; + max_redemptions: number | string | null; + metadata: Record | null; + minimum_amount: number | string | null; + minimum_amount_currency: string | null; + name: string | null; + provider: string; + provider_id: string; + raw: unknown; + redemption_count: number | string | null; + sandbox: boolean | null; + starts_at: Date | string | null; + status: string | null; + } & Record + >({ + sql: `SELECT provider, provider_id, sandbox, code, name, status, active, customer_provider_id, discount_type, discount_value, discount_currency, starts_at, expires_at, max_redemptions, redemption_count, minimum_amount, minimum_amount_currency, metadata, data, raw, deleted_at${fields.length === 0 ? '' : `, ${fields.map((field) => `${quoteIdentifier(field.column)} AS ${quoteIdentifier(field.key)}`).join(', ')}`} + FROM ${tableName(schema, 'coupons')} + WHERE provider = $1 AND sandbox = $2 AND provider_id = $3 AND deleted_at IS NULL + LIMIT 1`, + params: [provider, sandbox, id], + }); + + if (!row) return null; + + const data = hydrateStoredData(row.data, fields, row); + + return withRaw( + { + ...data, + id: row.provider_id, + provider: row.provider, + sandbox: row.sandbox ?? false, + code: row.code, + name: row.name ?? undefined, + status: (row.status ?? 'inactive') as never, + active: row.active ?? undefined, + customerId: row.customer_provider_id ?? undefined, + discount: { + type: (row.discount_type ?? 'percentage') as + | 'percentage' + | 'fixed', + value: toNullableNumber(row.discount_value) ?? 0, + currency: row.discount_currency ?? undefined, + }, + startsAt: toIsoString(row.starts_at) ?? undefined, + expiresAt: toIsoString(row.expires_at) ?? undefined, + minimumAmount: toNullableNumber(row.minimum_amount) ?? undefined, + minimumAmountCurrency: row.minimum_amount_currency ?? undefined, + redemptions: { + count: toNullableNumber(row.redemption_count) ?? 0, + max: toNullableNumber(row.max_redemptions), + }, + metadata: row.metadata ?? undefined, + }, + row.raw, + options?.includeRaw, + ) as never; + }, + async findByCode(schema, provider, sandbox, code, options) { + const fields = Object.values(schema.tables.coupons.fields); + const [row] = await executor.query< + { + active: boolean | null; + code: string; + customer_provider_id: string | null; + data: Record | null; + discount_currency: string | null; + discount_type: string | null; + discount_value: number | string | null; + expires_at: Date | string | null; + max_redemptions: number | string | null; + metadata: Record | null; + minimum_amount: number | string | null; + minimum_amount_currency: string | null; + name: string | null; + provider: string; + provider_id: string; + raw: unknown; + redemption_count: number | string | null; + sandbox: boolean | null; + starts_at: Date | string | null; + status: string | null; + } & Record + >({ + sql: `SELECT provider, provider_id, sandbox, code, name, status, active, customer_provider_id, discount_type, discount_value, discount_currency, starts_at, expires_at, max_redemptions, redemption_count, minimum_amount, minimum_amount_currency, metadata, data, raw${fields.length === 0 ? '' : `, ${fields.map((field) => `${quoteIdentifier(field.column)} AS ${quoteIdentifier(field.key)}`).join(', ')}`} + FROM ${tableName(schema, 'coupons')} + WHERE provider = $1 AND sandbox = $2 AND code = $3 AND deleted_at IS NULL + LIMIT 1`, + params: [provider, sandbox, code], + }); + + if (!row) return null; + + const data = hydrateStoredData(row.data, fields, row); + + return withRaw( + { + ...data, + id: row.provider_id, + provider: row.provider, + sandbox: row.sandbox ?? false, + code: row.code, + name: row.name ?? undefined, + status: (row.status ?? 'inactive') as never, + active: row.active ?? undefined, + customerId: row.customer_provider_id ?? undefined, + discount: { + type: (row.discount_type ?? 'percentage') as + | 'percentage' + | 'fixed', + value: toNullableNumber(row.discount_value) ?? 0, + currency: row.discount_currency ?? undefined, + }, + startsAt: toIsoString(row.starts_at) ?? undefined, + expiresAt: toIsoString(row.expires_at) ?? undefined, + minimumAmount: toNullableNumber(row.minimum_amount) ?? undefined, + minimumAmountCurrency: row.minimum_amount_currency ?? undefined, + redemptions: { + count: toNullableNumber(row.redemption_count) ?? 0, + max: toNullableNumber(row.max_redemptions), + }, + metadata: row.metadata ?? undefined, + }, + row.raw, + options?.includeRaw, + ) as never; + }, + async list(schema, provider, sandbox, options) { + const fields = Object.values(schema.tables.coupons.fields); + const limit = options?.limit ?? 20; + if (!Number.isInteger(limit) || limit <= 0) { + throw new PaymeshError({ + code: 'invalid_request', + message: 'Coupon list limit must be a positive integer', + }); + } + if (options?.after && options?.before) { + throw new PaymeshError({ + code: 'invalid_request', + message: 'Coupon list accepts either "after" or "before", not both', + }); + } + + const cursor = decodeCustomerCursor( + options?.before ?? options?.after, + options?.before ? 'before' : 'after', + ); + const includeRaw = options?.includeRaw; + const countParams: SqlValue[] = [provider, sandbox]; + const countFilters: string[] = [ + 'provider = $1', + 'sandbox = $2', + 'deleted_at IS NULL', + ]; + if (options?.code) { + countParams.push(options.code); + countFilters.push(`code = $${countParams.length}`); + } + if (typeof options?.active === 'boolean') { + countParams.push(options.active); + countFilters.push(`active = $${countParams.length}`); + } + const [totalRow] = await executor.query<{ total: number | string }>({ + sql: `SELECT COUNT(*) AS total FROM ${tableName(schema, 'coupons')} WHERE ${countFilters.join(' AND ')}`, + params: countParams, + }); + const params: SqlValue[] = [...countParams]; + const filters = [...countFilters]; + let order = 'ASC'; + + if (cursor) { + params.push(cursor.value.createdAt, cursor.value.providerId); + filters.push( + cursor.mode === 'before' + ? `(created_at, provider_id) < ($${params.length - 1}, $${params.length})` + : `(created_at, provider_id) > ($${params.length - 1}, $${params.length})`, + ); + if (cursor.mode === 'before') order = 'DESC'; + } + + params.push(limit + 1); + const rows = await executor.query< + { + active: boolean | null; + code: string; + customer_provider_id: string | null; + created_at: Date | string; + data: Record | null; + discount_currency: string | null; + discount_type: string | null; + discount_value: number | string | null; + expires_at: Date | string | null; + max_redemptions: number | string | null; + metadata: Record | null; + minimum_amount: number | string | null; + minimum_amount_currency: string | null; + name: string | null; + provider: string; + provider_id: string; + raw: unknown; + redemption_count: number | string | null; + sandbox: boolean | null; + starts_at: Date | string | null; + status: string | null; + } & Record + >({ + sql: `SELECT provider, provider_id, sandbox, created_at, code, name, status, active, customer_provider_id, discount_type, discount_value, discount_currency, starts_at, expires_at, max_redemptions, redemption_count, minimum_amount, minimum_amount_currency, metadata, data, raw${fields.length === 0 ? '' : `, ${fields.map((field) => `${quoteIdentifier(field.column)} AS ${quoteIdentifier(field.key)}`).join(', ')}`} + FROM ${tableName(schema, 'coupons')} + WHERE ${filters.join(' AND ')} + ORDER BY created_at ${order}, provider_id ${order} + LIMIT $${params.length}`, + params, + }); + + const hasExtra = rows.length > limit; + const windowRows = hasExtra ? rows.slice(0, limit) : rows; + const pageRows = + cursor?.mode === 'before' ? [...windowRows].reverse() : windowRows; + const data = pageRows.map((row) => { + const data = hydrateStoredData(row.data, fields, row); + + return withRaw( + { + ...data, + id: row.provider_id, + provider: row.provider, + sandbox: row.sandbox ?? false, + code: row.code, + name: row.name ?? undefined, + status: (row.status ?? 'inactive') as never, + active: row.active ?? undefined, + customerId: row.customer_provider_id ?? undefined, + discount: { + type: (row.discount_type ?? 'percentage') as + | 'percentage' + | 'fixed', + value: toNullableNumber(row.discount_value) ?? 0, + currency: row.discount_currency ?? undefined, + }, + startsAt: toIsoString(row.starts_at) ?? undefined, + expiresAt: toIsoString(row.expires_at) ?? undefined, + minimumAmount: toNullableNumber(row.minimum_amount) ?? undefined, + minimumAmountCurrency: row.minimum_amount_currency ?? undefined, + redemptions: { + count: toNullableNumber(row.redemption_count) ?? 0, + max: toNullableNumber(row.max_redemptions), + }, + metadata: row.metadata ?? undefined, + }, + row.raw, + includeRaw, + ) as never; + }); + + return { + data, + total: Number(totalRow?.total ?? 0), + previous: + data.length === 0 + ? null + : cursor?.mode === 'before' + ? hasExtra + ? encodeCustomerCursor(pageRows[0]!) + : null + : cursor + ? encodeCustomerCursor(pageRows[0]!) + : null, + next: + data.length === 0 + ? null + : cursor?.mode === 'before' + ? encodeCustomerCursor(pageRows[pageRows.length - 1]!) + : hasExtra + ? encodeCustomerCursor(pageRows[pageRows.length - 1]!) + : null, + }; + }, + upsert: (schema, coupon) => + upsertByProviderId(executor, schema, 'coupons', { + provider: coupon.provider, + provider_id: coupon.id, + version: getVersion(coupon, getInternalRaw(coupon)), + sandbox: coupon.sandbox, + code: coupon.code, + name: coupon.name ?? null, + status: coupon.status, + active: coupon.active ?? null, + customer_provider_id: coupon.customerId ?? null, + discount_type: coupon.discount.type, + discount_value: coupon.discount.value, + discount_currency: + coupon.discount.type === 'fixed' + ? (coupon.discount.currency ?? null) + : null, + starts_at: coupon.startsAt ?? null, + expires_at: coupon.expiresAt ?? null, + max_redemptions: coupon.redemptions.max, + redemption_count: coupon.redemptions.count, + minimum_amount: coupon.minimumAmount ?? null, + minimum_amount_currency: coupon.minimumAmountCurrency ?? null, + metadata: coupon.metadata ?? null, + data: withoutSchemaFields( + withoutRaw(coupon), + schema.tables.coupons.fields, + ), + raw: getPersistableRaw(executor, coupon), + deleted_at: null, + updated_at: new Date().toISOString(), + ...getExtraFieldValues(schema, 'coupons', coupon), + }), + markDeleted: (schema, coupon) => + executor.execute({ + sql: `UPDATE ${tableName(schema, 'coupons')} + SET version = $4, data = $5, raw = $6, deleted_at = NOW(), updated_at = NOW() + WHERE provider = $1 AND sandbox = $2 AND provider_id = $3`, + params: [ + coupon.provider, + coupon.sandbox, + coupon.id, + getVersion(coupon, getInternalRaw(coupon)), + withoutRaw(coupon), + getPersistableRaw(executor, coupon), + ], + }), + }, pix: { async findByProviderId(schema, provider, sandbox, id, options) { const fields = Object.values(schema.tables.pix.fields); diff --git a/packages/memory/src/repositories.ts b/packages/memory/src/repositories.ts index 0b86b9f..10d3f9f 100644 --- a/packages/memory/src/repositories.ts +++ b/packages/memory/src/repositories.ts @@ -1,6 +1,7 @@ import type { BaseAnyPayment, BasePix, + PaymeshCouponListOptions, PaymeshDatabaseRepositories, PaymeshRepositoryReadOptions, ResolvedDatabaseSchema, @@ -235,6 +236,170 @@ export function createRepositories( }); }, }, + coupons: { + async findByProviderId(_schema, provider, sandbox, id, readOptions) { + const coupon = options + .getState() + .coupons.get(entityKey(provider, sandbox, id)); + if (!coupon || coupon.deletedAt) return null; + + return mapStoredCoupon(coupon, readOptions) as never; + }, + async findByCode(_schema, provider, sandbox, code, readOptions) { + const coupon = [...options.getState().coupons.values()].find( + (row) => + row.provider === provider && + row.sandbox === sandbox && + row.deletedAt === null && + row.code === code, + ); + if (!coupon) return null; + + return mapStoredCoupon(coupon, readOptions) as never; + }, + async upsert(schema, coupon) { + validateRequiredString(coupon.id, 'id', schema.tables.coupons.name); + validateRequiredString( + coupon.provider, + 'provider', + schema.tables.coupons.name, + ); + validateRequiredBoolean( + coupon.sandbox, + 'sandbox', + schema.tables.coupons.name, + ); + validateRequiredString(coupon.code, 'code', schema.tables.coupons.name); + + const prepared = applyTableFieldDefaults( + schema.tables.coupons, + coupon as Record, + ); + validateRequiredTableFields(schema, 'coupons', prepared); + const couponRecord = prepared as Record; + + const key = entityKey( + couponRecord.provider as string, + couponRecord.sandbox as boolean, + couponRecord.id as string, + ); + const existing = options.getState().coupons.get(key); + const createdAt = existing?.createdAt ?? new Date().toISOString(); + options.getState().coupons.set(key, { + ...stripTableFieldKeys(withoutRaw(couponRecord), schema, 'coupons'), + ...couponRecord, + createdAt, + updatedAt: new Date().toISOString(), + deletedAt: null, + raw: getPersistableRaw(options.persistRaw, coupon), + version: getVersion(coupon, getPersistableRaw(true, coupon)), + } as never); + }, + async list(_schema, provider, sandbox, listOptions) { + const limit = listOptions?.limit ?? 20; + if (!Number.isInteger(limit) || limit <= 0) { + throw new PaymeshError({ + code: 'invalid_request', + message: 'Coupon list limit must be a positive integer', + }); + } + if (listOptions?.after && listOptions?.before) { + throw new PaymeshError({ + code: 'invalid_request', + message: 'Coupon list accepts either "after" or "before", not both', + }); + } + + const cursor = decodeCustomerCursor( + listOptions?.before ?? listOptions?.after, + listOptions?.before ? 'before' : 'after', + ); + const filtered = [...options.getState().coupons.values()] + .filter( + (coupon) => + coupon.provider === provider && + coupon.sandbox === sandbox && + coupon.deletedAt === null && + (listOptions?.code ? coupon.code === listOptions.code : true) && + (typeof listOptions?.active === 'boolean' + ? coupon.active === listOptions.active + : true), + ) + .sort(compareCustomerRows); + + let pageSource = filtered; + if (cursor?.mode === 'after') { + pageSource = filtered.filter( + (coupon) => compareCustomerRowToCursor(coupon, cursor.value) > 0, + ); + } else if (cursor?.mode === 'before') { + pageSource = filtered + .filter( + (coupon) => compareCustomerRowToCursor(coupon, cursor.value) < 0, + ) + .sort((left, right) => compareCustomerRows(right, left)); + } + + const hasExtra = pageSource.length > limit; + const windowRows = hasExtra ? pageSource.slice(0, limit) : pageSource; + const pageRows = + cursor?.mode === 'before' ? [...windowRows].reverse() : windowRows; + const data = pageRows.map((coupon) => + mapStoredCoupon(coupon, listOptions), + ); + + return { + data, + total: filtered.length, + previous: + data.length === 0 + ? null + : cursor?.mode === 'before' + ? hasExtra + ? encodeCustomerCursor({ + createdAt: pageRows[0]!.createdAt, + providerId: pageRows[0]!.id, + }) + : null + : cursor + ? encodeCustomerCursor({ + createdAt: pageRows[0]!.createdAt, + providerId: pageRows[0]!.id, + }) + : null, + next: + data.length === 0 + ? null + : cursor?.mode === 'before' + ? encodeCustomerCursor({ + createdAt: pageRows[pageRows.length - 1]!.createdAt, + providerId: pageRows[pageRows.length - 1]!.id, + }) + : hasExtra + ? encodeCustomerCursor({ + createdAt: pageRows[pageRows.length - 1]!.createdAt, + providerId: pageRows[pageRows.length - 1]!.id, + }) + : null, + } as never; + }, + async markDeleted(_schema, coupon) { + const key = entityKey(coupon.provider, coupon.sandbox, coupon.id); + const existing = options.getState().coupons.get(key); + options.getState().coupons.set(key, { + id: coupon.id, + code: existing?.code ?? coupon.code ?? coupon.id, + provider: coupon.provider, + sandbox: coupon.sandbox, + createdAt: existing?.createdAt ?? new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: new Date().toISOString(), + raw: getPersistableRaw(options.persistRaw, coupon), + deleted: true, + version: getVersion(coupon, getPersistableRaw(true, coupon)), + }); + }, + }, pix: { async findByProviderId(_schema, provider, sandbox, id, readOptions) { const pix = options @@ -561,6 +726,27 @@ function mapStoredCustomer( return withRaw(cloneValue(data), raw, readOptions?.includeRaw) as never; } +function mapStoredCoupon( + coupon: Record & { + id: string; + provider: string; + sandbox: boolean; + raw: unknown; + }, + readOptions?: + | PaymeshCouponListOptions + | PaymeshRepositoryReadOptions, +) { + const { + raw, + createdAt: _createdAt, + updatedAt: _updatedAt, + deletedAt: _deletedAt, + ...data + } = coupon; + return withRaw(cloneValue(data), raw, readOptions?.includeRaw) as never; +} + function mapStoredPayment( payment: Record & { raw: unknown; diff --git a/packages/memory/src/state.ts b/packages/memory/src/state.ts index 0d32ff7..ccd57f0 100644 --- a/packages/memory/src/state.ts +++ b/packages/memory/src/state.ts @@ -9,6 +9,7 @@ import { } from './shared/schema'; import type { MemoryDatabaseSeed, + MemorySeedCoupon, MemorySeedCustomer, MemorySeedPayment, MemorySeedPix, @@ -42,6 +43,26 @@ export interface StoredCustomer extends Record { raw: unknown; } +/** Shape of a coupon record as stored in the in-memory database. */ +export interface StoredCoupon extends Record { + /** Unique coupon identifier assigned by the provider. */ + id: string; + /** Provider identifier that owns this coupon. */ + provider: string; + /** Whether this record belongs to the sandbox environment. */ + sandbox: boolean; + /** Coupon code shown to end users. */ + code: string; + /** ISO-8601 creation timestamp. */ + createdAt: string; + /** ISO-8601 last update timestamp. */ + updatedAt: string; + /** ISO-8601 soft-deletion timestamp, or `null` if active. */ + deletedAt: string | null; + /** Persisted raw provider payload, or `null` when `persistRaw` is disabled. */ + raw: unknown; +} + /** Shape of a payment record (pix, checkout, or invoice) as stored in memory. */ export interface StoredPayment extends Record { /** Unique payment identifier assigned by the provider. */ @@ -137,6 +158,8 @@ export interface StoredCatalogRecord extends Record { export interface MemoryDatabaseState { /** Customer records indexed by composite entity key. */ customers: Map; + /** Coupon records indexed by composite entity key. */ + coupons: Map; /** Pix payment records indexed by composite entity key. */ pix: Map; /** Checkout payment records indexed by composite entity key. */ @@ -159,6 +182,7 @@ export interface MemoryDatabaseState { export function createEmptyState(): MemoryDatabaseState { return { customers: new Map(), + coupons: new Map(), pix: new Map(), checkouts: new Map(), invoices: new Map(), @@ -179,6 +203,7 @@ export function createEmptyState(): MemoryDatabaseState { export function cloneState(state: MemoryDatabaseState): MemoryDatabaseState { return { customers: cloneMap(state.customers), + coupons: cloneMap(state.coupons), pix: cloneMap(state.pix), checkouts: cloneMap(state.checkouts), invoices: cloneMap(state.invoices), @@ -359,6 +384,10 @@ export function applySeed( insertSeedCustomer(state, schema, customer, strict, persistRaw); } + for (const coupon of seed.coupons ?? []) { + insertSeedCoupon(state, schema, coupon, strict, persistRaw); + } + for (const product of seed.products ?? []) { insertSeedProduct(state, schema, product, strict, persistRaw); } @@ -455,6 +484,61 @@ function insertSeedCustomer( } as never); } +function insertSeedCoupon( + state: MemoryDatabaseState, + schema: ResolvedDatabaseSchema, + coupon: MemorySeedCoupon, + strict: boolean, + persistRaw: boolean, +) { + validateRequiredString(coupon.id, 'id', schema.tables.coupons.name); + validateRequiredString( + coupon.provider, + 'provider', + schema.tables.coupons.name, + ); + validateRequiredBoolean( + coupon.sandbox, + 'sandbox', + schema.tables.coupons.name, + ); + validateRequiredString(coupon.code, 'code', schema.tables.coupons.name); + + const next = applyTableFieldDefaults( + schema.tables.coupons, + coupon as unknown as Record, + ); + validateRequiredTableFields(schema, 'coupons', next); + const couponRecord = next as Record; + + const key = entityKey( + couponRecord.provider as string, + couponRecord.sandbox as boolean, + couponRecord.id as string, + ); + validateUniqueInsert( + strict, + state.coupons.has(key), + schema.tables.coupons.name, + couponRecord.id as string, + ); + const now = + typeof couponRecord.createdAt === 'string' + ? couponRecord.createdAt + : new Date().toISOString(); + state.coupons.set(key, { + ...stripTableFieldKeys(couponRecord, schema, 'coupons'), + ...couponRecord, + createdAt: now, + updatedAt: + typeof couponRecord.updatedAt === 'string' ? couponRecord.updatedAt : now, + deletedAt: couponRecord.deleted ? now : null, + raw: persistRaw + ? (couponRecord.raw ?? getInternalRaw(couponRecord) ?? null) + : null, + } as never); +} + function insertSeedPayment( state: MemoryDatabaseState, schema: ResolvedDatabaseSchema, diff --git a/packages/memory/src/types.ts b/packages/memory/src/types.ts index 265b9ca..d731818 100644 --- a/packages/memory/src/types.ts +++ b/packages/memory/src/types.ts @@ -1,5 +1,6 @@ import type { BaseAnyPayment, + BaseCoupon, BaseCustomer, BasePaymeshEvent, BasePix, @@ -24,6 +25,15 @@ export type MemorySeedCustomer = BaseCustomer & raw?: unknown; }; +/** Coupon record used for seeding the in-memory database. */ +export type MemorySeedCoupon = BaseCoupon & + MemorySeedTimestamps & { + /** Marks the coupon as soft-deleted at seed time. */ + deleted?: boolean; + /** Optional raw provider payload to persist when `persistRaw` is enabled. */ + raw?: unknown; + }; + /** Payment record (checkout or invoice) used for seeding the in-memory database. */ export type MemorySeedPayment = BaseAnyPayment & MemorySeedTimestamps & { @@ -91,6 +101,8 @@ export type MemorySeedPrice = ProviderCatalogPrice & export interface MemoryDatabaseSeed { /** Customer records to seed. */ customers?: MemorySeedCustomer[]; + /** Coupon records to seed. */ + coupons?: MemorySeedCoupon[]; /** Pix payment records to seed. */ pix?: MemorySeedPix[]; /** Checkout payment records to seed. */ diff --git a/packages/postgres/src/repositories.ts b/packages/postgres/src/repositories.ts index 0257aa2..6899a64 100644 --- a/packages/postgres/src/repositories.ts +++ b/packages/postgres/src/repositories.ts @@ -279,6 +279,344 @@ export function createRepositories( ], }), }, + coupons: { + async findByProviderId(schema, provider, sandbox, id, options) { + const fields = Object.values(schema.tables.coupons.fields); + const [row] = await executor.query< + { + active: boolean | null; + code: string; + customer_provider_id: string | null; + data: Record | null; + deleted_at: Date | string | null; + discount_currency: string | null; + discount_type: string | null; + discount_value: number | string | null; + expires_at: Date | string | null; + max_redemptions: number | string | null; + metadata: Record | null; + minimum_amount: number | string | null; + minimum_amount_currency: string | null; + name: string | null; + provider: string; + provider_id: string; + raw: unknown; + redemption_count: number | string | null; + sandbox: boolean | null; + starts_at: Date | string | null; + status: string | null; + } & Record + >({ + sql: `SELECT provider, provider_id, sandbox, code, name, status, active, customer_provider_id, discount_type, discount_value, discount_currency, starts_at, expires_at, max_redemptions, redemption_count, minimum_amount, minimum_amount_currency, metadata, data, raw, deleted_at${fields.length === 0 ? '' : `, ${fields.map((field) => `${quoteIdentifier(field.column)} AS ${quoteIdentifier(field.key)}`).join(', ')}`} + FROM ${tableName(schema, 'coupons')} + WHERE provider = $1 AND sandbox = $2 AND provider_id = $3 AND deleted_at IS NULL + LIMIT 1`, + params: [provider, sandbox, id], + }); + + if (!row) return null; + + const data = hydrateStoredData(row.data, fields, row); + + return withRaw( + { + ...data, + id: row.provider_id, + provider: row.provider, + sandbox: row.sandbox ?? false, + code: row.code, + name: row.name ?? undefined, + status: (row.status ?? 'inactive') as never, + active: row.active ?? undefined, + customerId: row.customer_provider_id ?? undefined, + discount: { + type: (row.discount_type ?? 'percentage') as + | 'percentage' + | 'fixed', + value: toNullableNumber(row.discount_value) ?? 0, + currency: row.discount_currency ?? undefined, + }, + startsAt: toIsoString(row.starts_at) ?? undefined, + expiresAt: toIsoString(row.expires_at) ?? undefined, + minimumAmount: toNullableNumber(row.minimum_amount) ?? undefined, + minimumAmountCurrency: row.minimum_amount_currency ?? undefined, + redemptions: { + count: toNullableNumber(row.redemption_count) ?? 0, + max: toNullableNumber(row.max_redemptions), + }, + metadata: row.metadata ?? undefined, + }, + row.raw, + options?.includeRaw, + ) as never; + }, + async findByCode(schema, provider, sandbox, code, options) { + const fields = Object.values(schema.tables.coupons.fields); + const [row] = await executor.query< + { + active: boolean | null; + code: string; + customer_provider_id: string | null; + data: Record | null; + discount_currency: string | null; + discount_type: string | null; + discount_value: number | string | null; + expires_at: Date | string | null; + max_redemptions: number | string | null; + metadata: Record | null; + minimum_amount: number | string | null; + minimum_amount_currency: string | null; + name: string | null; + provider: string; + provider_id: string; + raw: unknown; + redemption_count: number | string | null; + sandbox: boolean | null; + starts_at: Date | string | null; + status: string | null; + } & Record + >({ + sql: `SELECT provider, provider_id, sandbox, code, name, status, active, customer_provider_id, discount_type, discount_value, discount_currency, starts_at, expires_at, max_redemptions, redemption_count, minimum_amount, minimum_amount_currency, metadata, data, raw${fields.length === 0 ? '' : `, ${fields.map((field) => `${quoteIdentifier(field.column)} AS ${quoteIdentifier(field.key)}`).join(', ')}`} + FROM ${tableName(schema, 'coupons')} + WHERE provider = $1 AND sandbox = $2 AND code = $3 AND deleted_at IS NULL + LIMIT 1`, + params: [provider, sandbox, code], + }); + + if (!row) return null; + + const data = hydrateStoredData(row.data, fields, row); + + return withRaw( + { + ...data, + id: row.provider_id, + provider: row.provider, + sandbox: row.sandbox ?? false, + code: row.code, + name: row.name ?? undefined, + status: (row.status ?? 'inactive') as never, + active: row.active ?? undefined, + customerId: row.customer_provider_id ?? undefined, + discount: { + type: (row.discount_type ?? 'percentage') as + | 'percentage' + | 'fixed', + value: toNullableNumber(row.discount_value) ?? 0, + currency: row.discount_currency ?? undefined, + }, + startsAt: toIsoString(row.starts_at) ?? undefined, + expiresAt: toIsoString(row.expires_at) ?? undefined, + minimumAmount: toNullableNumber(row.minimum_amount) ?? undefined, + minimumAmountCurrency: row.minimum_amount_currency ?? undefined, + redemptions: { + count: toNullableNumber(row.redemption_count) ?? 0, + max: toNullableNumber(row.max_redemptions), + }, + metadata: row.metadata ?? undefined, + }, + row.raw, + options?.includeRaw, + ) as never; + }, + async list(schema, provider, sandbox, options) { + const fields = Object.values(schema.tables.coupons.fields); + const limit = options?.limit ?? 20; + if (!Number.isInteger(limit) || limit <= 0) { + throw new PaymeshError({ + code: 'invalid_request', + message: 'Coupon list limit must be a positive integer', + }); + } + if (options?.after && options?.before) { + throw new PaymeshError({ + code: 'invalid_request', + message: 'Coupon list accepts either "after" or "before", not both', + }); + } + + const cursor = decodeCustomerCursor( + options?.before ?? options?.after, + options?.before ? 'before' : 'after', + ); + const includeRaw = options?.includeRaw; + const countParams: SqlValue[] = [provider, sandbox]; + const countFilters: string[] = [ + 'provider = $1', + 'sandbox = $2', + 'deleted_at IS NULL', + ]; + if (options?.code) { + countParams.push(options.code); + countFilters.push(`code = $${countParams.length}`); + } + if (typeof options?.active === 'boolean') { + countParams.push(options.active); + countFilters.push(`active = $${countParams.length}`); + } + const [totalRow] = await executor.query<{ total: number | string }>({ + sql: `SELECT COUNT(*) AS total FROM ${tableName(schema, 'coupons')} WHERE ${countFilters.join(' AND ')}`, + params: countParams, + }); + const params: SqlValue[] = [...countParams]; + const filters = [...countFilters]; + let order = 'ASC'; + + if (cursor) { + params.push(cursor.value.createdAt, cursor.value.providerId); + filters.push( + cursor.mode === 'before' + ? `(created_at, provider_id) < ($${params.length - 1}, $${params.length})` + : `(created_at, provider_id) > ($${params.length - 1}, $${params.length})`, + ); + if (cursor.mode === 'before') order = 'DESC'; + } + + params.push(limit + 1); + const rows = await executor.query< + { + active: boolean | null; + code: string; + customer_provider_id: string | null; + created_at: Date | string; + data: Record | null; + discount_currency: string | null; + discount_type: string | null; + discount_value: number | string | null; + expires_at: Date | string | null; + max_redemptions: number | string | null; + metadata: Record | null; + minimum_amount: number | string | null; + minimum_amount_currency: string | null; + name: string | null; + provider: string; + provider_id: string; + raw: unknown; + redemption_count: number | string | null; + sandbox: boolean | null; + starts_at: Date | string | null; + status: string | null; + } & Record + >({ + sql: `SELECT provider, provider_id, sandbox, created_at, code, name, status, active, customer_provider_id, discount_type, discount_value, discount_currency, starts_at, expires_at, max_redemptions, redemption_count, minimum_amount, minimum_amount_currency, metadata, data, raw${fields.length === 0 ? '' : `, ${fields.map((field) => `${quoteIdentifier(field.column)} AS ${quoteIdentifier(field.key)}`).join(', ')}`} + FROM ${tableName(schema, 'coupons')} + WHERE ${filters.join(' AND ')} + ORDER BY created_at ${order}, provider_id ${order} + LIMIT $${params.length}`, + params, + }); + + const hasExtra = rows.length > limit; + const windowRows = hasExtra ? rows.slice(0, limit) : rows; + const pageRows = + cursor?.mode === 'before' ? [...windowRows].reverse() : windowRows; + const data = pageRows.map((row) => { + const data = hydrateStoredData(row.data, fields, row); + + return withRaw( + { + ...data, + id: row.provider_id, + provider: row.provider, + sandbox: row.sandbox ?? false, + code: row.code, + name: row.name ?? undefined, + status: (row.status ?? 'inactive') as never, + active: row.active ?? undefined, + customerId: row.customer_provider_id ?? undefined, + discount: { + type: (row.discount_type ?? 'percentage') as + | 'percentage' + | 'fixed', + value: toNullableNumber(row.discount_value) ?? 0, + currency: row.discount_currency ?? undefined, + }, + startsAt: toIsoString(row.starts_at) ?? undefined, + expiresAt: toIsoString(row.expires_at) ?? undefined, + minimumAmount: toNullableNumber(row.minimum_amount) ?? undefined, + minimumAmountCurrency: row.minimum_amount_currency ?? undefined, + redemptions: { + count: toNullableNumber(row.redemption_count) ?? 0, + max: toNullableNumber(row.max_redemptions), + }, + metadata: row.metadata ?? undefined, + }, + row.raw, + includeRaw, + ) as never; + }); + + return { + data, + total: Number(totalRow?.total ?? 0), + previous: + data.length === 0 + ? null + : cursor?.mode === 'before' + ? hasExtra + ? encodeCustomerCursor(pageRows[0]!) + : null + : cursor + ? encodeCustomerCursor(pageRows[0]!) + : null, + next: + data.length === 0 + ? null + : cursor?.mode === 'before' + ? encodeCustomerCursor(pageRows[pageRows.length - 1]!) + : hasExtra + ? encodeCustomerCursor(pageRows[pageRows.length - 1]!) + : null, + }; + }, + upsert: (schema, coupon) => + upsertByProviderId(executor, schema, 'coupons', { + provider: coupon.provider, + provider_id: coupon.id, + version: getVersion(coupon, getInternalRaw(coupon)), + sandbox: coupon.sandbox, + code: coupon.code, + name: coupon.name ?? null, + status: coupon.status, + active: coupon.active ?? null, + customer_provider_id: coupon.customerId ?? null, + discount_type: coupon.discount.type, + discount_value: coupon.discount.value, + discount_currency: + coupon.discount.type === 'fixed' + ? (coupon.discount.currency ?? null) + : null, + starts_at: coupon.startsAt ?? null, + expires_at: coupon.expiresAt ?? null, + max_redemptions: coupon.redemptions.max, + redemption_count: coupon.redemptions.count, + minimum_amount: coupon.minimumAmount ?? null, + minimum_amount_currency: coupon.minimumAmountCurrency ?? null, + metadata: coupon.metadata ?? null, + data: withoutSchemaFields( + withoutRaw(coupon), + schema.tables.coupons.fields, + ), + raw: getPersistableRaw(executor, coupon), + deleted_at: null, + updated_at: new Date().toISOString(), + ...getExtraFieldValues(schema, 'coupons', coupon), + }), + markDeleted: (schema, coupon) => + executor.execute({ + sql: `UPDATE ${tableName(schema, 'coupons')} + SET version = $4, data = $5, raw = $6, deleted_at = NOW(), updated_at = NOW() + WHERE provider = $1 AND sandbox = $2 AND provider_id = $3`, + params: [ + coupon.provider, + coupon.sandbox, + coupon.id, + getVersion(coupon, getInternalRaw(coupon)), + withoutRaw(coupon), + getPersistableRaw(executor, coupon), + ], + }), + }, pix: { async findByProviderId(schema, provider, sandbox, id, options) { const fields = Object.values(schema.tables.pix.fields); diff --git a/packages/prisma/src/repositories.ts b/packages/prisma/src/repositories.ts index af5828e..6f4080e 100644 --- a/packages/prisma/src/repositories.ts +++ b/packages/prisma/src/repositories.ts @@ -279,6 +279,344 @@ export function createRepositories( ], }), }, + coupons: { + async findByProviderId(schema, provider, sandbox, id, options) { + const fields = Object.values(schema.tables.coupons.fields); + const [row] = await executor.query< + { + active: boolean | null; + code: string; + customer_provider_id: string | null; + data: Record | null; + deleted_at: Date | string | null; + discount_currency: string | null; + discount_type: string | null; + discount_value: number | string | null; + expires_at: Date | string | null; + max_redemptions: number | string | null; + metadata: Record | null; + minimum_amount: number | string | null; + minimum_amount_currency: string | null; + name: string | null; + provider: string; + provider_id: string; + raw: unknown; + redemption_count: number | string | null; + sandbox: boolean | null; + starts_at: Date | string | null; + status: string | null; + } & Record + >({ + sql: `SELECT provider, provider_id, sandbox, code, name, status, active, customer_provider_id, discount_type, discount_value, discount_currency, starts_at, expires_at, max_redemptions, redemption_count, minimum_amount, minimum_amount_currency, metadata, data, raw, deleted_at${fields.length === 0 ? '' : `, ${fields.map((field) => `${quoteIdentifier(field.column)} AS ${quoteIdentifier(field.key)}`).join(', ')}`} + FROM ${tableName(schema, 'coupons')} + WHERE provider = $1 AND sandbox = $2 AND provider_id = $3 AND deleted_at IS NULL + LIMIT 1`, + params: [provider, sandbox, id], + }); + + if (!row) return null; + + const data = hydrateStoredData(row.data, fields, row); + + return withRaw( + { + ...data, + id: row.provider_id, + provider: row.provider, + sandbox: row.sandbox ?? false, + code: row.code, + name: row.name ?? undefined, + status: (row.status ?? 'inactive') as never, + active: row.active ?? undefined, + customerId: row.customer_provider_id ?? undefined, + discount: { + type: (row.discount_type ?? 'percentage') as + | 'percentage' + | 'fixed', + value: toNullableNumber(row.discount_value) ?? 0, + currency: row.discount_currency ?? undefined, + }, + startsAt: toIsoString(row.starts_at) ?? undefined, + expiresAt: toIsoString(row.expires_at) ?? undefined, + minimumAmount: toNullableNumber(row.minimum_amount) ?? undefined, + minimumAmountCurrency: row.minimum_amount_currency ?? undefined, + redemptions: { + count: toNullableNumber(row.redemption_count) ?? 0, + max: toNullableNumber(row.max_redemptions), + }, + metadata: row.metadata ?? undefined, + }, + row.raw, + options?.includeRaw, + ) as never; + }, + async findByCode(schema, provider, sandbox, code, options) { + const fields = Object.values(schema.tables.coupons.fields); + const [row] = await executor.query< + { + active: boolean | null; + code: string; + customer_provider_id: string | null; + data: Record | null; + discount_currency: string | null; + discount_type: string | null; + discount_value: number | string | null; + expires_at: Date | string | null; + max_redemptions: number | string | null; + metadata: Record | null; + minimum_amount: number | string | null; + minimum_amount_currency: string | null; + name: string | null; + provider: string; + provider_id: string; + raw: unknown; + redemption_count: number | string | null; + sandbox: boolean | null; + starts_at: Date | string | null; + status: string | null; + } & Record + >({ + sql: `SELECT provider, provider_id, sandbox, code, name, status, active, customer_provider_id, discount_type, discount_value, discount_currency, starts_at, expires_at, max_redemptions, redemption_count, minimum_amount, minimum_amount_currency, metadata, data, raw${fields.length === 0 ? '' : `, ${fields.map((field) => `${quoteIdentifier(field.column)} AS ${quoteIdentifier(field.key)}`).join(', ')}`} + FROM ${tableName(schema, 'coupons')} + WHERE provider = $1 AND sandbox = $2 AND code = $3 AND deleted_at IS NULL + LIMIT 1`, + params: [provider, sandbox, code], + }); + + if (!row) return null; + + const data = hydrateStoredData(row.data, fields, row); + + return withRaw( + { + ...data, + id: row.provider_id, + provider: row.provider, + sandbox: row.sandbox ?? false, + code: row.code, + name: row.name ?? undefined, + status: (row.status ?? 'inactive') as never, + active: row.active ?? undefined, + customerId: row.customer_provider_id ?? undefined, + discount: { + type: (row.discount_type ?? 'percentage') as + | 'percentage' + | 'fixed', + value: toNullableNumber(row.discount_value) ?? 0, + currency: row.discount_currency ?? undefined, + }, + startsAt: toIsoString(row.starts_at) ?? undefined, + expiresAt: toIsoString(row.expires_at) ?? undefined, + minimumAmount: toNullableNumber(row.minimum_amount) ?? undefined, + minimumAmountCurrency: row.minimum_amount_currency ?? undefined, + redemptions: { + count: toNullableNumber(row.redemption_count) ?? 0, + max: toNullableNumber(row.max_redemptions), + }, + metadata: row.metadata ?? undefined, + }, + row.raw, + options?.includeRaw, + ) as never; + }, + async list(schema, provider, sandbox, options) { + const fields = Object.values(schema.tables.coupons.fields); + const limit = options?.limit ?? 20; + if (!Number.isInteger(limit) || limit <= 0) { + throw new PaymeshError({ + code: 'invalid_request', + message: 'Coupon list limit must be a positive integer', + }); + } + if (options?.after && options?.before) { + throw new PaymeshError({ + code: 'invalid_request', + message: 'Coupon list accepts either "after" or "before", not both', + }); + } + + const cursor = decodeCustomerCursor( + options?.before ?? options?.after, + options?.before ? 'before' : 'after', + ); + const includeRaw = options?.includeRaw; + const countParams: SqlValue[] = [provider, sandbox]; + const countFilters: string[] = [ + 'provider = $1', + 'sandbox = $2', + 'deleted_at IS NULL', + ]; + if (options?.code) { + countParams.push(options.code); + countFilters.push(`code = $${countParams.length}`); + } + if (typeof options?.active === 'boolean') { + countParams.push(options.active); + countFilters.push(`active = $${countParams.length}`); + } + const [totalRow] = await executor.query<{ total: number | string }>({ + sql: `SELECT COUNT(*) AS total FROM ${tableName(schema, 'coupons')} WHERE ${countFilters.join(' AND ')}`, + params: countParams, + }); + const params: SqlValue[] = [...countParams]; + const filters = [...countFilters]; + let order = 'ASC'; + + if (cursor) { + params.push(cursor.value.createdAt, cursor.value.providerId); + filters.push( + cursor.mode === 'before' + ? `(created_at, provider_id) < ($${params.length - 1}, $${params.length})` + : `(created_at, provider_id) > ($${params.length - 1}, $${params.length})`, + ); + if (cursor.mode === 'before') order = 'DESC'; + } + + params.push(limit + 1); + const rows = await executor.query< + { + active: boolean | null; + code: string; + customer_provider_id: string | null; + created_at: Date | string; + data: Record | null; + discount_currency: string | null; + discount_type: string | null; + discount_value: number | string | null; + expires_at: Date | string | null; + max_redemptions: number | string | null; + metadata: Record | null; + minimum_amount: number | string | null; + minimum_amount_currency: string | null; + name: string | null; + provider: string; + provider_id: string; + raw: unknown; + redemption_count: number | string | null; + sandbox: boolean | null; + starts_at: Date | string | null; + status: string | null; + } & Record + >({ + sql: `SELECT provider, provider_id, sandbox, created_at, code, name, status, active, customer_provider_id, discount_type, discount_value, discount_currency, starts_at, expires_at, max_redemptions, redemption_count, minimum_amount, minimum_amount_currency, metadata, data, raw${fields.length === 0 ? '' : `, ${fields.map((field) => `${quoteIdentifier(field.column)} AS ${quoteIdentifier(field.key)}`).join(', ')}`} + FROM ${tableName(schema, 'coupons')} + WHERE ${filters.join(' AND ')} + ORDER BY created_at ${order}, provider_id ${order} + LIMIT $${params.length}`, + params, + }); + + const hasExtra = rows.length > limit; + const windowRows = hasExtra ? rows.slice(0, limit) : rows; + const pageRows = + cursor?.mode === 'before' ? [...windowRows].reverse() : windowRows; + const data = pageRows.map((row) => { + const data = hydrateStoredData(row.data, fields, row); + + return withRaw( + { + ...data, + id: row.provider_id, + provider: row.provider, + sandbox: row.sandbox ?? false, + code: row.code, + name: row.name ?? undefined, + status: (row.status ?? 'inactive') as never, + active: row.active ?? undefined, + customerId: row.customer_provider_id ?? undefined, + discount: { + type: (row.discount_type ?? 'percentage') as + | 'percentage' + | 'fixed', + value: toNullableNumber(row.discount_value) ?? 0, + currency: row.discount_currency ?? undefined, + }, + startsAt: toIsoString(row.starts_at) ?? undefined, + expiresAt: toIsoString(row.expires_at) ?? undefined, + minimumAmount: toNullableNumber(row.minimum_amount) ?? undefined, + minimumAmountCurrency: row.minimum_amount_currency ?? undefined, + redemptions: { + count: toNullableNumber(row.redemption_count) ?? 0, + max: toNullableNumber(row.max_redemptions), + }, + metadata: row.metadata ?? undefined, + }, + row.raw, + includeRaw, + ) as never; + }); + + return { + data, + total: Number(totalRow?.total ?? 0), + previous: + data.length === 0 + ? null + : cursor?.mode === 'before' + ? hasExtra + ? encodeCustomerCursor(pageRows[0]!) + : null + : cursor + ? encodeCustomerCursor(pageRows[0]!) + : null, + next: + data.length === 0 + ? null + : cursor?.mode === 'before' + ? encodeCustomerCursor(pageRows[pageRows.length - 1]!) + : hasExtra + ? encodeCustomerCursor(pageRows[pageRows.length - 1]!) + : null, + }; + }, + upsert: (schema, coupon) => + upsertByProviderId(executor, schema, 'coupons', { + provider: coupon.provider, + provider_id: coupon.id, + version: getVersion(coupon, getInternalRaw(coupon)), + sandbox: coupon.sandbox, + code: coupon.code, + name: coupon.name ?? null, + status: coupon.status, + active: coupon.active ?? null, + customer_provider_id: coupon.customerId ?? null, + discount_type: coupon.discount.type, + discount_value: coupon.discount.value, + discount_currency: + coupon.discount.type === 'fixed' + ? (coupon.discount.currency ?? null) + : null, + starts_at: coupon.startsAt ?? null, + expires_at: coupon.expiresAt ?? null, + max_redemptions: coupon.redemptions.max, + redemption_count: coupon.redemptions.count, + minimum_amount: coupon.minimumAmount ?? null, + minimum_amount_currency: coupon.minimumAmountCurrency ?? null, + metadata: coupon.metadata ?? null, + data: withoutSchemaFields( + withoutRaw(coupon), + schema.tables.coupons.fields, + ), + raw: getPersistableRaw(executor, coupon), + deleted_at: null, + updated_at: new Date().toISOString(), + ...getExtraFieldValues(schema, 'coupons', coupon), + }), + markDeleted: (schema, coupon) => + executor.execute({ + sql: `UPDATE ${tableName(schema, 'coupons')} + SET version = $4, data = $5, raw = $6, deleted_at = NOW(), updated_at = NOW() + WHERE provider = $1 AND sandbox = $2 AND provider_id = $3`, + params: [ + coupon.provider, + coupon.sandbox, + coupon.id, + getVersion(coupon, getInternalRaw(coupon)), + withoutRaw(coupon), + getPersistableRaw(executor, coupon), + ], + }), + }, pix: { async findByProviderId(schema, provider, sandbox, id, options) { const fields = Object.values(schema.tables.pix.fields); From f0c33c1f042d4280e71dcee01e942cb43c8a6d2c Mon Sep 17 00:00:00 2001 From: almeida Date: Sat, 13 Jun 2026 12:11:20 -0300 Subject: [PATCH 10/15] feat(web): document coupons in new providers --- apps/web/content/docs/concepts/api.mdx | 15 +++++++++++++++ .../web/content/docs/guides/create-a-provider.mdx | 2 ++ 2 files changed, 17 insertions(+) diff --git a/apps/web/content/docs/concepts/api.mdx b/apps/web/content/docs/concepts/api.mdx index cb38d36..de293df 100644 --- a/apps/web/content/docs/concepts/api.mdx +++ b/apps/web/content/docs/concepts/api.mdx @@ -53,6 +53,7 @@ It accepts: The client managers exposed today are: - `payments` +- `coupons` - `pix` - `customers` - `webhooks` @@ -61,6 +62,20 @@ The client managers exposed today are: Plugins may add more client surface through `extends()`. +`payments.create()` now also accepts: + +- `couponCode?` +- `allowCouponCodes?` + +Providers that advertise `coupons` expose: + +- `coupons.create()` +- `coupons.get()` +- `coupons.list()` +- `coupons.update()` +- `coupons.delete()` +- `coupons.check()` + ## Capability model Providers declare capabilities instead of pretending every provider is equivalent. diff --git a/apps/web/content/docs/guides/create-a-provider.mdx b/apps/web/content/docs/guides/create-a-provider.mdx index b0dafc1..aafc381 100644 --- a/apps/web/content/docs/guides/create-a-provider.mdx +++ b/apps/web/content/docs/guides/create-a-provider.mdx @@ -170,6 +170,8 @@ Available input fields: - `amount` - `currency` - `productIds?` +- `couponCode?` +- `allowCouponCodes?` - `customer?` - `description?` - `metadata?` From 73f44490db168c0c37592ba637ba58b805996390 Mon Sep 17 00:00:00 2001 From: almeida Date: Sat, 13 Jun 2026 12:18:12 -0300 Subject: [PATCH 11/15] feat(web): add new page to document coupons --- apps/web/.source/browser.ts | 2 +- apps/web/.source/server.ts | 73 ++-- apps/web/content/docs/concepts/coupons.mdx | 440 +++++++++++++++++++++ apps/web/src/lib/docs-navigation.ts | 5 + 4 files changed, 483 insertions(+), 37 deletions(-) create mode 100644 apps/web/content/docs/concepts/coupons.mdx diff --git a/apps/web/.source/browser.ts b/apps/web/.source/browser.ts index ecc2455..c472ec6 100644 --- a/apps/web/.source/browser.ts +++ b/apps/web/.source/browser.ts @@ -7,6 +7,6 @@ const create = browser(); const browserCollections = { - docs: create.doc("docs", {"basic-usage.mdx": () => import("../content/docs/basic-usage.mdx?collection=docs"), "comparison.mdx": () => import("../content/docs/comparison.mdx?collection=docs"), "installation.mdx": () => import("../content/docs/installation.mdx?collection=docs"), "introduction.mdx": () => import("../content/docs/introduction.mdx?collection=docs"), "mcp.mdx": () => import("../content/docs/mcp.mdx?collection=docs"), "adapters/elysia.mdx": () => import("../content/docs/adapters/elysia.mdx?collection=docs"), "adapters/express.mdx": () => import("../content/docs/adapters/express.mdx?collection=docs"), "adapters/fastify.mdx": () => import("../content/docs/adapters/fastify.mdx?collection=docs"), "adapters/hono.mdx": () => import("../content/docs/adapters/hono.mdx?collection=docs"), "adapters/next.mdx": () => import("../content/docs/adapters/next.mdx?collection=docs"), "ai-resources/skills.mdx": () => import("../content/docs/ai-resources/skills.mdx?collection=docs"), "concepts/api.mdx": () => import("../content/docs/concepts/api.mdx?collection=docs"), "concepts/client.mdx": () => import("../content/docs/concepts/client.mdx?collection=docs"), "concepts/customers.mdx": () => import("../content/docs/concepts/customers.mdx?collection=docs"), "concepts/hooks.mdx": () => import("../content/docs/concepts/hooks.mdx?collection=docs"), "concepts/payment-providers.mdx": () => import("../content/docs/concepts/payment-providers.mdx?collection=docs"), "concepts/pix.mdx": () => import("../content/docs/concepts/pix.mdx?collection=docs"), "concepts/sandbox.mdx": () => import("../content/docs/concepts/sandbox.mdx?collection=docs"), "concepts/typescript.mdx": () => import("../content/docs/concepts/typescript.mdx?collection=docs"), "database/drizzle.mdx": () => import("../content/docs/database/drizzle.mdx?collection=docs"), "database/memory.mdx": () => import("../content/docs/database/memory.mdx?collection=docs"), "database/overview.mdx": () => import("../content/docs/database/overview.mdx?collection=docs"), "database/postgres.mdx": () => import("../content/docs/database/postgres.mdx?collection=docs"), "database/prisma.mdx": () => import("../content/docs/database/prisma.mdx?collection=docs"), "guides/create-a-database-adapter.mdx": () => import("../content/docs/guides/create-a-database-adapter.mdx?collection=docs"), "guides/create-a-provider.mdx": () => import("../content/docs/guides/create-a-provider.mdx?collection=docs"), "guides/create-your-first-plugin.mdx": () => import("../content/docs/guides/create-your-first-plugin.mdx?collection=docs"), "guides/database.mdx": () => import("../content/docs/guides/database.mdx?collection=docs"), "guides/optimize-performance.mdx": () => import("../content/docs/guides/optimize-performance.mdx?collection=docs"), "guides/plugins.mdx": () => import("../content/docs/guides/plugins.mdx?collection=docs"), "guides/provider-selection.mdx": () => import("../content/docs/guides/provider-selection.mdx?collection=docs"), "guides/testing.mdx": () => import("../content/docs/guides/testing.mdx?collection=docs"), "guides/webhooks-at-scale.mdx": () => import("../content/docs/guides/webhooks-at-scale.mdx?collection=docs"), "guides/webhooks.mdx": () => import("../content/docs/guides/webhooks.mdx?collection=docs"), "providers/abacatepay.mdx": () => import("../content/docs/providers/abacatepay.mdx?collection=docs"), "providers/dodo.mdx": () => import("../content/docs/providers/dodo.mdx?collection=docs"), "providers/polar.mdx": () => import("../content/docs/providers/polar.mdx?collection=docs"), "providers/stripe.mdx": () => import("../content/docs/providers/stripe.mdx?collection=docs"), "plugins/audit-logs.mdx": () => import("../content/docs/plugins/audit-logs.mdx?collection=docs"), "plugins/dash.mdx": () => import("../content/docs/plugins/dash.mdx?collection=docs"), "plugins/overview.mdx": () => import("../content/docs/plugins/overview.mdx?collection=docs"), "reference/cli.mdx": () => import("../content/docs/reference/cli.mdx?collection=docs"), "reference/client-options.mdx": () => import("../content/docs/reference/client-options.mdx?collection=docs"), "reference/database-schema.mdx": () => import("../content/docs/reference/database-schema.mdx?collection=docs"), "reference/errors.mdx": () => import("../content/docs/reference/errors.mdx?collection=docs"), "reference/plugin-api.mdx": () => import("../content/docs/reference/plugin-api.mdx?collection=docs"), }), + docs: create.doc("docs", {"basic-usage.mdx": () => import("../content/docs/basic-usage.mdx?collection=docs"), "comparison.mdx": () => import("../content/docs/comparison.mdx?collection=docs"), "installation.mdx": () => import("../content/docs/installation.mdx?collection=docs"), "introduction.mdx": () => import("../content/docs/introduction.mdx?collection=docs"), "mcp.mdx": () => import("../content/docs/mcp.mdx?collection=docs"), "adapters/elysia.mdx": () => import("../content/docs/adapters/elysia.mdx?collection=docs"), "adapters/express.mdx": () => import("../content/docs/adapters/express.mdx?collection=docs"), "adapters/fastify.mdx": () => import("../content/docs/adapters/fastify.mdx?collection=docs"), "adapters/hono.mdx": () => import("../content/docs/adapters/hono.mdx?collection=docs"), "adapters/next.mdx": () => import("../content/docs/adapters/next.mdx?collection=docs"), "ai-resources/skills.mdx": () => import("../content/docs/ai-resources/skills.mdx?collection=docs"), "database/drizzle.mdx": () => import("../content/docs/database/drizzle.mdx?collection=docs"), "database/memory.mdx": () => import("../content/docs/database/memory.mdx?collection=docs"), "database/overview.mdx": () => import("../content/docs/database/overview.mdx?collection=docs"), "database/postgres.mdx": () => import("../content/docs/database/postgres.mdx?collection=docs"), "database/prisma.mdx": () => import("../content/docs/database/prisma.mdx?collection=docs"), "concepts/api.mdx": () => import("../content/docs/concepts/api.mdx?collection=docs"), "concepts/client.mdx": () => import("../content/docs/concepts/client.mdx?collection=docs"), "concepts/coupons.mdx": () => import("../content/docs/concepts/coupons.mdx?collection=docs"), "concepts/customers.mdx": () => import("../content/docs/concepts/customers.mdx?collection=docs"), "concepts/hooks.mdx": () => import("../content/docs/concepts/hooks.mdx?collection=docs"), "concepts/payment-providers.mdx": () => import("../content/docs/concepts/payment-providers.mdx?collection=docs"), "concepts/pix.mdx": () => import("../content/docs/concepts/pix.mdx?collection=docs"), "concepts/sandbox.mdx": () => import("../content/docs/concepts/sandbox.mdx?collection=docs"), "concepts/typescript.mdx": () => import("../content/docs/concepts/typescript.mdx?collection=docs"), "guides/create-a-database-adapter.mdx": () => import("../content/docs/guides/create-a-database-adapter.mdx?collection=docs"), "guides/create-a-provider.mdx": () => import("../content/docs/guides/create-a-provider.mdx?collection=docs"), "guides/create-your-first-plugin.mdx": () => import("../content/docs/guides/create-your-first-plugin.mdx?collection=docs"), "guides/database.mdx": () => import("../content/docs/guides/database.mdx?collection=docs"), "guides/optimize-performance.mdx": () => import("../content/docs/guides/optimize-performance.mdx?collection=docs"), "guides/plugins.mdx": () => import("../content/docs/guides/plugins.mdx?collection=docs"), "guides/provider-selection.mdx": () => import("../content/docs/guides/provider-selection.mdx?collection=docs"), "guides/testing.mdx": () => import("../content/docs/guides/testing.mdx?collection=docs"), "guides/webhooks-at-scale.mdx": () => import("../content/docs/guides/webhooks-at-scale.mdx?collection=docs"), "guides/webhooks.mdx": () => import("../content/docs/guides/webhooks.mdx?collection=docs"), "providers/abacatepay.mdx": () => import("../content/docs/providers/abacatepay.mdx?collection=docs"), "providers/dodo.mdx": () => import("../content/docs/providers/dodo.mdx?collection=docs"), "providers/polar.mdx": () => import("../content/docs/providers/polar.mdx?collection=docs"), "providers/stripe.mdx": () => import("../content/docs/providers/stripe.mdx?collection=docs"), "plugins/audit-logs.mdx": () => import("../content/docs/plugins/audit-logs.mdx?collection=docs"), "plugins/dash.mdx": () => import("../content/docs/plugins/dash.mdx?collection=docs"), "plugins/overview.mdx": () => import("../content/docs/plugins/overview.mdx?collection=docs"), "reference/cli.mdx": () => import("../content/docs/reference/cli.mdx?collection=docs"), "reference/client-options.mdx": () => import("../content/docs/reference/client-options.mdx?collection=docs"), "reference/database-schema.mdx": () => import("../content/docs/reference/database-schema.mdx?collection=docs"), "reference/errors.mdx": () => import("../content/docs/reference/errors.mdx?collection=docs"), "reference/plugin-api.mdx": () => import("../content/docs/reference/plugin-api.mdx?collection=docs"), }), }; export default browserCollections; \ No newline at end of file diff --git a/apps/web/.source/server.ts b/apps/web/.source/server.ts index a5e160a..69700dc 100644 --- a/apps/web/.source/server.ts +++ b/apps/web/.source/server.ts @@ -1,39 +1,40 @@ // @ts-nocheck -import { frontmatter as __fd_glob_45 } from "../content/docs/reference/plugin-api.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_44 } from "../content/docs/reference/errors.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_43 } from "../content/docs/reference/database-schema.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_42 } from "../content/docs/reference/client-options.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_41 } from "../content/docs/reference/cli.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_40 } from "../content/docs/plugins/overview.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_39 } from "../content/docs/plugins/dash.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_38 } from "../content/docs/plugins/audit-logs.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_37 } from "../content/docs/providers/stripe.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_36 } from "../content/docs/providers/polar.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_35 } from "../content/docs/providers/dodo.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_34 } from "../content/docs/providers/abacatepay.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_33 } from "../content/docs/guides/webhooks.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_32 } from "../content/docs/guides/webhooks-at-scale.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_31 } from "../content/docs/guides/testing.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_30 } from "../content/docs/guides/provider-selection.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_29 } from "../content/docs/guides/plugins.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_28 } from "../content/docs/guides/optimize-performance.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_27 } from "../content/docs/guides/database.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_26 } from "../content/docs/guides/create-your-first-plugin.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_25 } from "../content/docs/guides/create-a-provider.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_24 } from "../content/docs/guides/create-a-database-adapter.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_23 } from "../content/docs/database/prisma.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_22 } from "../content/docs/database/postgres.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_21 } from "../content/docs/database/overview.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_20 } from "../content/docs/database/memory.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_19 } from "../content/docs/database/drizzle.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_18 } from "../content/docs/concepts/typescript.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_17 } from "../content/docs/concepts/sandbox.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_16 } from "../content/docs/concepts/pix.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_15 } from "../content/docs/concepts/payment-providers.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_14 } from "../content/docs/concepts/hooks.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_13 } from "../content/docs/concepts/customers.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_12 } from "../content/docs/concepts/client.mdx?collection=docs&only=frontmatter" -import { frontmatter as __fd_glob_11 } from "../content/docs/concepts/api.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_46 } from "../content/docs/reference/plugin-api.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_45 } from "../content/docs/reference/errors.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_44 } from "../content/docs/reference/database-schema.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_43 } from "../content/docs/reference/client-options.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_42 } from "../content/docs/reference/cli.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_41 } from "../content/docs/plugins/overview.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_40 } from "../content/docs/plugins/dash.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_39 } from "../content/docs/plugins/audit-logs.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_38 } from "../content/docs/providers/stripe.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_37 } from "../content/docs/providers/polar.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_36 } from "../content/docs/providers/dodo.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_35 } from "../content/docs/providers/abacatepay.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_34 } from "../content/docs/guides/webhooks.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_33 } from "../content/docs/guides/webhooks-at-scale.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_32 } from "../content/docs/guides/testing.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_31 } from "../content/docs/guides/provider-selection.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_30 } from "../content/docs/guides/plugins.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_29 } from "../content/docs/guides/optimize-performance.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_28 } from "../content/docs/guides/database.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_27 } from "../content/docs/guides/create-your-first-plugin.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_26 } from "../content/docs/guides/create-a-provider.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_25 } from "../content/docs/guides/create-a-database-adapter.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_24 } from "../content/docs/concepts/typescript.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_23 } from "../content/docs/concepts/sandbox.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_22 } from "../content/docs/concepts/pix.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_21 } from "../content/docs/concepts/payment-providers.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_20 } from "../content/docs/concepts/hooks.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_19 } from "../content/docs/concepts/customers.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_18 } from "../content/docs/concepts/coupons.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_17 } from "../content/docs/concepts/client.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_16 } from "../content/docs/concepts/api.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_15 } from "../content/docs/database/prisma.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_14 } from "../content/docs/database/postgres.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_13 } from "../content/docs/database/overview.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_12 } from "../content/docs/database/memory.mdx?collection=docs&only=frontmatter" +import { frontmatter as __fd_glob_11 } from "../content/docs/database/drizzle.mdx?collection=docs&only=frontmatter" import { frontmatter as __fd_glob_10 } from "../content/docs/ai-resources/skills.mdx?collection=docs&only=frontmatter" import { frontmatter as __fd_glob_9 } from "../content/docs/adapters/next.mdx?collection=docs&only=frontmatter" import { frontmatter as __fd_glob_8 } from "../content/docs/adapters/hono.mdx?collection=docs&only=frontmatter" @@ -53,4 +54,4 @@ const create = server({"doc":{"passthroughs":["extractedReferences"]}}); -export const docs = await create.docsLazy("docs", "content/docs", {}, {"basic-usage.mdx": __fd_glob_0, "comparison.mdx": __fd_glob_1, "installation.mdx": __fd_glob_2, "introduction.mdx": __fd_glob_3, "mcp.mdx": __fd_glob_4, "adapters/elysia.mdx": __fd_glob_5, "adapters/express.mdx": __fd_glob_6, "adapters/fastify.mdx": __fd_glob_7, "adapters/hono.mdx": __fd_glob_8, "adapters/next.mdx": __fd_glob_9, "ai-resources/skills.mdx": __fd_glob_10, "concepts/api.mdx": __fd_glob_11, "concepts/client.mdx": __fd_glob_12, "concepts/customers.mdx": __fd_glob_13, "concepts/hooks.mdx": __fd_glob_14, "concepts/payment-providers.mdx": __fd_glob_15, "concepts/pix.mdx": __fd_glob_16, "concepts/sandbox.mdx": __fd_glob_17, "concepts/typescript.mdx": __fd_glob_18, "database/drizzle.mdx": __fd_glob_19, "database/memory.mdx": __fd_glob_20, "database/overview.mdx": __fd_glob_21, "database/postgres.mdx": __fd_glob_22, "database/prisma.mdx": __fd_glob_23, "guides/create-a-database-adapter.mdx": __fd_glob_24, "guides/create-a-provider.mdx": __fd_glob_25, "guides/create-your-first-plugin.mdx": __fd_glob_26, "guides/database.mdx": __fd_glob_27, "guides/optimize-performance.mdx": __fd_glob_28, "guides/plugins.mdx": __fd_glob_29, "guides/provider-selection.mdx": __fd_glob_30, "guides/testing.mdx": __fd_glob_31, "guides/webhooks-at-scale.mdx": __fd_glob_32, "guides/webhooks.mdx": __fd_glob_33, "providers/abacatepay.mdx": __fd_glob_34, "providers/dodo.mdx": __fd_glob_35, "providers/polar.mdx": __fd_glob_36, "providers/stripe.mdx": __fd_glob_37, "plugins/audit-logs.mdx": __fd_glob_38, "plugins/dash.mdx": __fd_glob_39, "plugins/overview.mdx": __fd_glob_40, "reference/cli.mdx": __fd_glob_41, "reference/client-options.mdx": __fd_glob_42, "reference/database-schema.mdx": __fd_glob_43, "reference/errors.mdx": __fd_glob_44, "reference/plugin-api.mdx": __fd_glob_45, }, {"basic-usage.mdx": () => import("../content/docs/basic-usage.mdx?collection=docs"), "comparison.mdx": () => import("../content/docs/comparison.mdx?collection=docs"), "installation.mdx": () => import("../content/docs/installation.mdx?collection=docs"), "introduction.mdx": () => import("../content/docs/introduction.mdx?collection=docs"), "mcp.mdx": () => import("../content/docs/mcp.mdx?collection=docs"), "adapters/elysia.mdx": () => import("../content/docs/adapters/elysia.mdx?collection=docs"), "adapters/express.mdx": () => import("../content/docs/adapters/express.mdx?collection=docs"), "adapters/fastify.mdx": () => import("../content/docs/adapters/fastify.mdx?collection=docs"), "adapters/hono.mdx": () => import("../content/docs/adapters/hono.mdx?collection=docs"), "adapters/next.mdx": () => import("../content/docs/adapters/next.mdx?collection=docs"), "ai-resources/skills.mdx": () => import("../content/docs/ai-resources/skills.mdx?collection=docs"), "concepts/api.mdx": () => import("../content/docs/concepts/api.mdx?collection=docs"), "concepts/client.mdx": () => import("../content/docs/concepts/client.mdx?collection=docs"), "concepts/customers.mdx": () => import("../content/docs/concepts/customers.mdx?collection=docs"), "concepts/hooks.mdx": () => import("../content/docs/concepts/hooks.mdx?collection=docs"), "concepts/payment-providers.mdx": () => import("../content/docs/concepts/payment-providers.mdx?collection=docs"), "concepts/pix.mdx": () => import("../content/docs/concepts/pix.mdx?collection=docs"), "concepts/sandbox.mdx": () => import("../content/docs/concepts/sandbox.mdx?collection=docs"), "concepts/typescript.mdx": () => import("../content/docs/concepts/typescript.mdx?collection=docs"), "database/drizzle.mdx": () => import("../content/docs/database/drizzle.mdx?collection=docs"), "database/memory.mdx": () => import("../content/docs/database/memory.mdx?collection=docs"), "database/overview.mdx": () => import("../content/docs/database/overview.mdx?collection=docs"), "database/postgres.mdx": () => import("../content/docs/database/postgres.mdx?collection=docs"), "database/prisma.mdx": () => import("../content/docs/database/prisma.mdx?collection=docs"), "guides/create-a-database-adapter.mdx": () => import("../content/docs/guides/create-a-database-adapter.mdx?collection=docs"), "guides/create-a-provider.mdx": () => import("../content/docs/guides/create-a-provider.mdx?collection=docs"), "guides/create-your-first-plugin.mdx": () => import("../content/docs/guides/create-your-first-plugin.mdx?collection=docs"), "guides/database.mdx": () => import("../content/docs/guides/database.mdx?collection=docs"), "guides/optimize-performance.mdx": () => import("../content/docs/guides/optimize-performance.mdx?collection=docs"), "guides/plugins.mdx": () => import("../content/docs/guides/plugins.mdx?collection=docs"), "guides/provider-selection.mdx": () => import("../content/docs/guides/provider-selection.mdx?collection=docs"), "guides/testing.mdx": () => import("../content/docs/guides/testing.mdx?collection=docs"), "guides/webhooks-at-scale.mdx": () => import("../content/docs/guides/webhooks-at-scale.mdx?collection=docs"), "guides/webhooks.mdx": () => import("../content/docs/guides/webhooks.mdx?collection=docs"), "providers/abacatepay.mdx": () => import("../content/docs/providers/abacatepay.mdx?collection=docs"), "providers/dodo.mdx": () => import("../content/docs/providers/dodo.mdx?collection=docs"), "providers/polar.mdx": () => import("../content/docs/providers/polar.mdx?collection=docs"), "providers/stripe.mdx": () => import("../content/docs/providers/stripe.mdx?collection=docs"), "plugins/audit-logs.mdx": () => import("../content/docs/plugins/audit-logs.mdx?collection=docs"), "plugins/dash.mdx": () => import("../content/docs/plugins/dash.mdx?collection=docs"), "plugins/overview.mdx": () => import("../content/docs/plugins/overview.mdx?collection=docs"), "reference/cli.mdx": () => import("../content/docs/reference/cli.mdx?collection=docs"), "reference/client-options.mdx": () => import("../content/docs/reference/client-options.mdx?collection=docs"), "reference/database-schema.mdx": () => import("../content/docs/reference/database-schema.mdx?collection=docs"), "reference/errors.mdx": () => import("../content/docs/reference/errors.mdx?collection=docs"), "reference/plugin-api.mdx": () => import("../content/docs/reference/plugin-api.mdx?collection=docs"), }); \ No newline at end of file +export const docs = await create.docsLazy("docs", "content/docs", {}, {"basic-usage.mdx": __fd_glob_0, "comparison.mdx": __fd_glob_1, "installation.mdx": __fd_glob_2, "introduction.mdx": __fd_glob_3, "mcp.mdx": __fd_glob_4, "adapters/elysia.mdx": __fd_glob_5, "adapters/express.mdx": __fd_glob_6, "adapters/fastify.mdx": __fd_glob_7, "adapters/hono.mdx": __fd_glob_8, "adapters/next.mdx": __fd_glob_9, "ai-resources/skills.mdx": __fd_glob_10, "database/drizzle.mdx": __fd_glob_11, "database/memory.mdx": __fd_glob_12, "database/overview.mdx": __fd_glob_13, "database/postgres.mdx": __fd_glob_14, "database/prisma.mdx": __fd_glob_15, "concepts/api.mdx": __fd_glob_16, "concepts/client.mdx": __fd_glob_17, "concepts/coupons.mdx": __fd_glob_18, "concepts/customers.mdx": __fd_glob_19, "concepts/hooks.mdx": __fd_glob_20, "concepts/payment-providers.mdx": __fd_glob_21, "concepts/pix.mdx": __fd_glob_22, "concepts/sandbox.mdx": __fd_glob_23, "concepts/typescript.mdx": __fd_glob_24, "guides/create-a-database-adapter.mdx": __fd_glob_25, "guides/create-a-provider.mdx": __fd_glob_26, "guides/create-your-first-plugin.mdx": __fd_glob_27, "guides/database.mdx": __fd_glob_28, "guides/optimize-performance.mdx": __fd_glob_29, "guides/plugins.mdx": __fd_glob_30, "guides/provider-selection.mdx": __fd_glob_31, "guides/testing.mdx": __fd_glob_32, "guides/webhooks-at-scale.mdx": __fd_glob_33, "guides/webhooks.mdx": __fd_glob_34, "providers/abacatepay.mdx": __fd_glob_35, "providers/dodo.mdx": __fd_glob_36, "providers/polar.mdx": __fd_glob_37, "providers/stripe.mdx": __fd_glob_38, "plugins/audit-logs.mdx": __fd_glob_39, "plugins/dash.mdx": __fd_glob_40, "plugins/overview.mdx": __fd_glob_41, "reference/cli.mdx": __fd_glob_42, "reference/client-options.mdx": __fd_glob_43, "reference/database-schema.mdx": __fd_glob_44, "reference/errors.mdx": __fd_glob_45, "reference/plugin-api.mdx": __fd_glob_46, }, {"basic-usage.mdx": () => import("../content/docs/basic-usage.mdx?collection=docs"), "comparison.mdx": () => import("../content/docs/comparison.mdx?collection=docs"), "installation.mdx": () => import("../content/docs/installation.mdx?collection=docs"), "introduction.mdx": () => import("../content/docs/introduction.mdx?collection=docs"), "mcp.mdx": () => import("../content/docs/mcp.mdx?collection=docs"), "adapters/elysia.mdx": () => import("../content/docs/adapters/elysia.mdx?collection=docs"), "adapters/express.mdx": () => import("../content/docs/adapters/express.mdx?collection=docs"), "adapters/fastify.mdx": () => import("../content/docs/adapters/fastify.mdx?collection=docs"), "adapters/hono.mdx": () => import("../content/docs/adapters/hono.mdx?collection=docs"), "adapters/next.mdx": () => import("../content/docs/adapters/next.mdx?collection=docs"), "ai-resources/skills.mdx": () => import("../content/docs/ai-resources/skills.mdx?collection=docs"), "database/drizzle.mdx": () => import("../content/docs/database/drizzle.mdx?collection=docs"), "database/memory.mdx": () => import("../content/docs/database/memory.mdx?collection=docs"), "database/overview.mdx": () => import("../content/docs/database/overview.mdx?collection=docs"), "database/postgres.mdx": () => import("../content/docs/database/postgres.mdx?collection=docs"), "database/prisma.mdx": () => import("../content/docs/database/prisma.mdx?collection=docs"), "concepts/api.mdx": () => import("../content/docs/concepts/api.mdx?collection=docs"), "concepts/client.mdx": () => import("../content/docs/concepts/client.mdx?collection=docs"), "concepts/coupons.mdx": () => import("../content/docs/concepts/coupons.mdx?collection=docs"), "concepts/customers.mdx": () => import("../content/docs/concepts/customers.mdx?collection=docs"), "concepts/hooks.mdx": () => import("../content/docs/concepts/hooks.mdx?collection=docs"), "concepts/payment-providers.mdx": () => import("../content/docs/concepts/payment-providers.mdx?collection=docs"), "concepts/pix.mdx": () => import("../content/docs/concepts/pix.mdx?collection=docs"), "concepts/sandbox.mdx": () => import("../content/docs/concepts/sandbox.mdx?collection=docs"), "concepts/typescript.mdx": () => import("../content/docs/concepts/typescript.mdx?collection=docs"), "guides/create-a-database-adapter.mdx": () => import("../content/docs/guides/create-a-database-adapter.mdx?collection=docs"), "guides/create-a-provider.mdx": () => import("../content/docs/guides/create-a-provider.mdx?collection=docs"), "guides/create-your-first-plugin.mdx": () => import("../content/docs/guides/create-your-first-plugin.mdx?collection=docs"), "guides/database.mdx": () => import("../content/docs/guides/database.mdx?collection=docs"), "guides/optimize-performance.mdx": () => import("../content/docs/guides/optimize-performance.mdx?collection=docs"), "guides/plugins.mdx": () => import("../content/docs/guides/plugins.mdx?collection=docs"), "guides/provider-selection.mdx": () => import("../content/docs/guides/provider-selection.mdx?collection=docs"), "guides/testing.mdx": () => import("../content/docs/guides/testing.mdx?collection=docs"), "guides/webhooks-at-scale.mdx": () => import("../content/docs/guides/webhooks-at-scale.mdx?collection=docs"), "guides/webhooks.mdx": () => import("../content/docs/guides/webhooks.mdx?collection=docs"), "providers/abacatepay.mdx": () => import("../content/docs/providers/abacatepay.mdx?collection=docs"), "providers/dodo.mdx": () => import("../content/docs/providers/dodo.mdx?collection=docs"), "providers/polar.mdx": () => import("../content/docs/providers/polar.mdx?collection=docs"), "providers/stripe.mdx": () => import("../content/docs/providers/stripe.mdx?collection=docs"), "plugins/audit-logs.mdx": () => import("../content/docs/plugins/audit-logs.mdx?collection=docs"), "plugins/dash.mdx": () => import("../content/docs/plugins/dash.mdx?collection=docs"), "plugins/overview.mdx": () => import("../content/docs/plugins/overview.mdx?collection=docs"), "reference/cli.mdx": () => import("../content/docs/reference/cli.mdx?collection=docs"), "reference/client-options.mdx": () => import("../content/docs/reference/client-options.mdx?collection=docs"), "reference/database-schema.mdx": () => import("../content/docs/reference/database-schema.mdx?collection=docs"), "reference/errors.mdx": () => import("../content/docs/reference/errors.mdx?collection=docs"), "reference/plugin-api.mdx": () => import("../content/docs/reference/plugin-api.mdx?collection=docs"), }); \ No newline at end of file diff --git a/apps/web/content/docs/concepts/coupons.mdx b/apps/web/content/docs/concepts/coupons.mdx new file mode 100644 index 0000000..cc0ecae --- /dev/null +++ b/apps/web/content/docs/concepts/coupons.mdx @@ -0,0 +1,440 @@ +--- +title: Coupons +description: Learn the full Paymesh coupons surface, including CRUD, checkout integration, validation previews, database persistence, provider support, and current provider-specific limits. +--- + +## Overview + +Paymesh exposes coupons as a first-party client surface: + +- `paymesh.coupons.create()` +- `paymesh.coupons.get()` +- `paymesh.coupons.list()` +- `paymesh.coupons.update()` +- `paymesh.coupons.delete()` +- `paymesh.coupons.check()` + +This is the normalized layer for provider-native coupon systems. In the current repository, coupon support is implemented for: + +- `@paymesh/stripe` +- `@paymesh/polar` + +Providers that do not advertise `capabilities.coupons === true` do not expose this surface. + +## Capability matrix + +| Provider | `coupons` | Notes | +| --- | --- | --- | +| Stripe | yes | Backed by Stripe coupons + promotion codes. | +| Polar | yes | Backed by Polar discounts. | +| AbacatePay | no | No coupon surface in the current provider package. | +| Dodo | no | No coupon surface in the current provider package. | + +## Quick start + +```ts title="src/lib/paymesh.ts" +import { createClient } from "paymesh"; +import { postgres } from "@paymesh/postgres"; +import { stripe } from "@paymesh/stripe"; + +export const paymesh = createClient({ + provider: stripe({ + secret: "sk_test_123", + webhookSecret: "whsec_123", + }), + database: postgres("postgres://postgres:postgres@localhost:5432/paymesh"), +}); +``` + +```ts title="src/server/coupons.ts" +const coupon = await paymesh.coupons.create({ + code: "WELCOME10", + name: "Welcome 10%", + discount: { + type: "percentage", + value: 10, + }, + duration: { + type: "once", + }, +}); +``` + +## Coupon shape + +The normalized coupon returned by Paymesh contains: + +```ts title="shape.ts" +type Coupon = { + id: string; + provider: string; + sandbox: boolean; + code: string; + name?: string; + status: "active" | "inactive" | "scheduled" | "expired" | "deleted"; + active?: boolean; + discount: + | { + type: "percentage"; + value: number; + } + | { + type: "fixed"; + value: number; + currency?: string; + amounts?: Record; + }; + duration?: { + type: "once" | "forever" | "repeating"; + durationInMonths?: number; + }; + startsAt?: string; + expiresAt?: string; + customerId?: string; + firstTimeOnly?: boolean; + minimumAmount?: number; + minimumAmountCurrency?: string; + appliesTo?: { + products?: string[]; + prices?: string[]; + }; + redemptions: { + count: number; + max: number | null; + }; + metadata?: Record; +}; +``` + +## Creating coupons + +### Percentage discount + +```ts title="percentage.ts" +const coupon = await paymesh.coupons.create({ + code: "WELCOME10", + name: "Welcome 10%", + discount: { + type: "percentage", + value: 10, + }, + duration: { + type: "once", + }, +}); +``` + +### Fixed discount + +```ts title="fixed.ts" +const coupon = await paymesh.coupons.create({ + code: "SAVE20", + name: "Save $20", + discount: { + type: "fixed", + value: 2000, + currency: "USD", + }, + duration: { + type: "once", + }, +}); +``` + +### Product-scoped coupon + +```ts title="scoped.ts" +const coupon = await paymesh.coupons.create({ + code: "PRO20", + discount: { + type: "percentage", + value: 20, + }, + appliesTo: { + products: ["prod_pro"], + }, +}); +``` + +### Expiration and redemption limits + +```ts title="limits.ts" +const coupon = await paymesh.coupons.create({ + code: "LAUNCH50", + discount: { + type: "percentage", + value: 50, + }, + expiresAt: "2026-12-31T23:59:59.000Z", + maxRedemptions: 100, +}); +``` + +## Reading and listing coupons + +```ts title="get.ts" +const coupon = await paymesh.coupons.get("promo_abc"); +``` + +```ts title="list.ts" +const page = await paymesh.coupons.list({ + limit: 20, + code: "WELCOME10", +}); +``` + +`list()` is paginated and returns: + +```ts title="list-result.ts" +{ + data: Coupon[]; + total: number; + previous: string | null; + next: string | null; +} +``` + +## Updating coupons + +```ts title="update.ts" +const coupon = await paymesh.coupons.update("promo_abc", { + active: false, + name: "Paused campaign", +}); +``` + +Update support is provider-dependent. Paymesh only forwards updates that the active provider supports with equivalent semantics. + +If the update request includes fields the provider cannot honor safely, Paymesh throws `invalid_request` instead of silently weakening the rule set. + +## Deleting coupons + +```ts title="delete.ts" +const result = await paymesh.coupons.delete("promo_abc"); +``` + +The normalized delete result is: + +```ts title="delete-result.ts" +{ + id: "promo_abc", + provider: "stripe", + sandbox: true, + deleted: true, + code: "WELCOME10", +} +``` + +## Checkout integration + +Coupons are integrated directly into `payments.create()`. + +### Pre-apply a coupon code + +```ts title="checkout-coupon.ts" +const checkout = await paymesh.payments.create({ + amount: 4900, + currency: "USD", + productIds: ["prod_pro"], + couponCode: "PRO20", +}); +``` + +### Allow manual coupon entry in hosted checkout + +```ts title="checkout-allow.ts" +const checkout = await paymesh.payments.create({ + amount: 4900, + currency: "USD", + productIds: ["prod_pro"], + allowCouponCodes: true, +}); +``` + +### Use both + +```ts title="checkout-both.ts" +const checkout = await paymesh.payments.create({ + amount: 4900, + currency: "USD", + productIds: ["prod_pro"], + couponCode: "PRO20", + allowCouponCodes: true, +}); +``` + +Current provider behavior: + +- Stripe enables `allow_promotion_codes` and pre-applies a matching promotion code. +- Polar enables `allow_discount_codes` and resolves `couponCode` into a `discount_id`. + +## Coupon validation with preview + +`coupons.check()` validates a code against the current cart or amount context and returns a normalized preview when Paymesh can calculate one safely. + +```ts title="check.ts" +const result = await paymesh.coupons.check({ + code: "WELCOME10", + amount: 10000, + currency: "USD", + productIds: ["prod_pro"], +}); +``` + +Successful result: + +```ts title="check-valid.ts" +{ + valid: true, + coupon: { + id: "promo_abc", + code: "WELCOME10", + discount: { + type: "percentage", + value: 10, + }, + }, + preview: { + subtotal: 10000, + discountTotal: 1000, + total: 9000, + currency: "USD", + }, +} +``` + +Failed result: + +```ts title="check-invalid.ts" +{ + valid: false, + reason: "minimum_amount_not_reached", + coupon: { + id: "promo_abc", + code: "WELCOME10", + }, +} +``` + +### Invalid reasons + +`coupons.check()` may return: + +- `not_found` +- `inactive` +- `expired` +- `not_started` +- `max_redemptions_reached` +- `customer_not_eligible` +- `product_not_eligible` +- `currency_not_supported` +- `minimum_amount_not_reached` +- `provider_error` + +### Preview sources + +Paymesh currently computes previews using the provider surface that exists today: + +- explicit `amount` + `currency` +- provider price lookups when the provider makes that practical +- product-based cart resolution when the provider catalog can be queried safely + +If the provider cannot validate a requested restriction with strong guarantees, the result is `valid: false` with `reason: "provider_error"` instead of a guessed approval. + +## Database persistence + +If a Paymesh database adapter is configured, coupons are persisted automatically. + +That persistence is useful for: + +- operational visibility +- local list queries +- stable coupon lookup by provider id +- keeping a normalized coupon record beside provider raw payloads + +The built-in schema now includes a `coupons` table. Paymesh stores normalized columns such as: + +- `provider` +- `provider_id` +- `code` +- `status` +- `active` +- `discount_type` +- `discount_value` +- `discount_currency` +- `starts_at` +- `expires_at` +- `max_redemptions` +- `redemption_count` +- `minimum_amount` +- `minimum_amount_currency` +- `metadata` +- `data` +- `raw` + +## Provider-specific behavior + +### Stripe + +Stripe coupon support is modeled around: + +- Stripe `coupon` +- Stripe `promotion_code` + +Important details: + +- the Paymesh `Coupon.id` maps to the Stripe promotion code id +- `couponCode` in checkout resolves a Stripe promotion code by `code` +- `allowCouponCodes` maps to Stripe Checkout `allow_promotion_codes` +- `startsAt` is not supported with equivalent semantics and is rejected +- `appliesTo.prices` is not supported and is rejected +- Stripe updates are intentionally narrow in the current implementation; only safe fields such as `active` and `metadata` are updated +- first-time-only restrictions may cause `check()` to return `provider_error` when Stripe cannot be validated locally with strong guarantees + +See [Stripe](/home/almeida/Documents/Projects/paymesh/paymesh/apps/web/content/docs/providers/stripe.mdx). + +### Polar + +Polar coupon support is modeled around Polar discounts. + +Important details: + +- `couponCode` in checkout resolves a Polar discount and passes its `discount_id` +- `allowCouponCodes` maps to Polar checkout manual discount entry +- `appliesTo.products` is supported +- `appliesTo.prices` is not supported and is rejected +- some Polar accounts may require `organizationId` in provider config for discount APIs + +See [Polar](/home/almeida/Documents/Projects/paymesh/paymesh/apps/web/content/docs/providers/polar.mdx). + +## Error behavior + +Paymesh treats unsupported coupon fields as hard errors. + +That means this fails intentionally: + +```ts title="unsupported.ts" +await paymesh.coupons.create({ + code: "PRICE20", + discount: { + type: "percentage", + value: 20, + }, + appliesTo: { + prices: ["price_monthly_pro"], + }, +}); +``` + +The goal is to avoid creating a provider-side coupon that is looser than the caller requested. + +## Recommended usage + +Use coupons when: + +- the provider already has native discount infrastructure you want to preserve +- the checkout should remain provider-hosted +- you want normalized CRUD and validation flows across providers +- you want coupon data persisted locally alongside the rest of the billing state + +Avoid assuming every provider supports identical coupon semantics. Paymesh normalizes the common surface, but provider-native rules still matter at the edges. diff --git a/apps/web/src/lib/docs-navigation.ts b/apps/web/src/lib/docs-navigation.ts index f793647..83897da 100644 --- a/apps/web/src/lib/docs-navigation.ts +++ b/apps/web/src/lib/docs-navigation.ts @@ -50,6 +50,11 @@ export const docsNavigation: DocNavGroup[] = [ href: '/docs/concepts/client', icon: 'terminal-square', }, + { + label: 'Coupons', + href: '/docs/concepts/coupons', + icon: 'ticket-percent', + }, { label: 'Sandbox', href: '/docs/concepts/sandbox', icon: 'flask' }, { label: 'Hooks', href: '/docs/concepts/hooks', icon: 'hook' }, { From a1708eeabfe1b31197488add969f65443d7b7c12 Mon Sep 17 00:00:00 2001 From: almeida Date: Sat, 13 Jun 2026 12:24:31 -0300 Subject: [PATCH 12/15] feat(coupons): add support to activate, deactivate and archive funcs --- apps/web/content/docs/concepts/api.mdx | 3 + apps/web/content/docs/concepts/coupons.mdx | 26 +++++ packages/paymesh/src/client/coupons.ts | 26 ++++- packages/paymesh/src/types/client.ts | 15 +++ packages/paymesh/test/client.test.ts | 119 ++++++++++++++++----- 5 files changed, 163 insertions(+), 26 deletions(-) diff --git a/apps/web/content/docs/concepts/api.mdx b/apps/web/content/docs/concepts/api.mdx index de293df..62080fb 100644 --- a/apps/web/content/docs/concepts/api.mdx +++ b/apps/web/content/docs/concepts/api.mdx @@ -73,7 +73,10 @@ Providers that advertise `coupons` expose: - `coupons.get()` - `coupons.list()` - `coupons.update()` +- `coupons.activate()` +- `coupons.deactivate()` - `coupons.delete()` +- `coupons.archive()` - `coupons.check()` ## Capability model diff --git a/apps/web/content/docs/concepts/coupons.mdx b/apps/web/content/docs/concepts/coupons.mdx index cc0ecae..8cea1c2 100644 --- a/apps/web/content/docs/concepts/coupons.mdx +++ b/apps/web/content/docs/concepts/coupons.mdx @@ -11,7 +11,10 @@ Paymesh exposes coupons as a first-party client surface: - `paymesh.coupons.get()` - `paymesh.coupons.list()` - `paymesh.coupons.update()` +- `paymesh.coupons.activate()` +- `paymesh.coupons.deactivate()` - `paymesh.coupons.delete()` +- `paymesh.coupons.archive()` - `paymesh.coupons.check()` This is the normalized layer for provider-native coupon systems. In the current repository, coupon support is implemented for: @@ -207,6 +210,29 @@ Update support is provider-dependent. Paymesh only forwards updates that the act If the update request includes fields the provider cannot honor safely, Paymesh throws `invalid_request` instead of silently weakening the rule set. +## Lifecycle helpers + +Paymesh also exposes convenience helpers for common coupon state transitions: + +```ts title="helpers.ts" +await paymesh.coupons.activate("promo_abc"); +await paymesh.coupons.deactivate("promo_abc"); +await paymesh.coupons.archive("promo_abc"); +``` + +Current helper semantics: + +- `activate(id)` delegates to `coupons.update(id, { active: true })` +- `deactivate(id)` delegates to `coupons.update(id, { active: false })` +- `archive(id)` delegates to `coupons.delete(id)` + +That means: + +- `activate()` and `deactivate()` return a normalized `Coupon` +- `archive()` returns the same normalized delete result as `coupons.delete()` + +Use these helpers when your app wants explicit intent without hand-writing tiny update payloads. + ## Deleting coupons ```ts title="delete.ts" diff --git a/packages/paymesh/src/client/coupons.ts b/packages/paymesh/src/client/coupons.ts index 84a6c9b..50bfcfa 100644 --- a/packages/paymesh/src/client/coupons.ts +++ b/packages/paymesh/src/client/coupons.ts @@ -44,7 +44,7 @@ export function createCouponsClient< const getProviderCoupons = () => getRequiredProviderFeature(provider.coupons, provider.id, 'coupons'); - return { + const client = { create: async ( data: PaymeshCouponCreateData, requestOptions?: ProviderRequestOptions, @@ -174,6 +174,24 @@ export function createCouponsClient< return resolvedCoupon; }, + activate: async ( + id: string, + requestOptions?: ProviderRequestOptions, + ) => + client.update( + id, + { active: true } as PaymeshCouponUpdateData, + requestOptions, + ), + deactivate: async ( + id: string, + requestOptions?: ProviderRequestOptions, + ) => + client.update( + id, + { active: false } as PaymeshCouponUpdateData, + requestOptions, + ), delete: async ( id: string, requestOptions?: ProviderRequestOptions, @@ -191,6 +209,10 @@ export function createCouponsClient< return result; }, + archive: async ( + id: string, + requestOptions?: ProviderRequestOptions, + ) => client.delete(id, requestOptions), check: async ( data: CouponCheckData, requestOptions?: ProviderRequestOptions, @@ -212,4 +234,6 @@ export function createCouponsClient< return result as PaymeshCouponCheckResult; }, }; + + return client; } diff --git a/packages/paymesh/src/types/client.ts b/packages/paymesh/src/types/client.ts index 53e9398..951c251 100644 --- a/packages/paymesh/src/types/client.ts +++ b/packages/paymesh/src/types/client.ts @@ -405,11 +405,26 @@ export interface PaymeshCouponsClient< data: PaymeshCouponUpdateData, options?: ProviderRequestOptions, ): Promise>; + /** Marks a coupon as active through the configured provider. */ + activate( + id: string, + options?: ProviderRequestOptions, + ): Promise>; + /** Marks a coupon as inactive through the configured provider. */ + deactivate( + id: string, + options?: ProviderRequestOptions, + ): Promise>; /** Deletes a coupon through the configured provider. */ delete( id: string, options?: ProviderRequestOptions, ): Promise>; + /** Archives a coupon through the configured provider. */ + archive( + id: string, + options?: ProviderRequestOptions, + ): Promise>; /** Validates a coupon code against the provided cart context. */ check( data: CouponCheckData, diff --git a/packages/paymesh/test/client.test.ts b/packages/paymesh/test/client.test.ts index 1e26276..85548fc 100644 --- a/packages/paymesh/test/client.test.ts +++ b/packages/paymesh/test/client.test.ts @@ -4,6 +4,7 @@ import { type CouponCheckData, type CouponCheckResult, type CouponCreateData, + type CouponDeleteResult, type Customer, createClient, defineDatabaseAdapter, @@ -189,6 +190,59 @@ describe('client', () => { }); }); + test('provides coupon lifecycle helpers', async () => { + const updates: Array = []; + const deletes: string[] = []; + const client = createClient({ + provider: createStubProvider({ + onCouponUpdate: async (_id, data, options) => { + updates.push(data); + return withRaw( + { + id: 'promo_123', + provider: 'stub', + sandbox: false, + code: 'WELCOME10', + status: (data.active ? 'active' : 'inactive') as + | 'active' + | 'inactive', + active: + typeof data.active === 'boolean' ? data.active : undefined, + discount: { type: 'percentage', value: 10 }, + redemptions: { count: 0, max: null }, + }, + { id: 'raw_coupon_123' }, + options?.includeRaw, + ); + }, + onCouponDelete: async (id, options) => { + deletes.push(id); + return withRaw( + { + id, + provider: 'stub', + sandbox: false, + deleted: true, + code: 'WELCOME10', + }, + { id: 'raw_coupon_123' }, + options?.includeRaw, + ); + }, + }), + }); + + const activated = await client.coupons.activate('promo_123'); + const deactivated = await client.coupons.deactivate('promo_123'); + const archived = await client.coupons.archive('promo_123'); + + expect(updates).toEqual([{ active: true }, { active: false }]); + expect(deletes).toEqual(['promo_123']); + expect(activated.active).toBe(true); + expect(deactivated.active).toBe(false); + expect(archived.deleted).toBe(true); + }); + test('delegates client.isSandbox() to the provider', () => { const client = createClient({ provider: createStubProvider({ sandbox: true }), @@ -1546,6 +1600,8 @@ function createStubProvider({ onCouponCreate, onCouponGet, onCouponCheck, + onCouponUpdate, + onCouponDelete, }: { sandbox?: boolean; onPaymentCreate?: ( @@ -1585,6 +1641,15 @@ function createStubProvider({ data: CouponCheckData, options?: ProviderRequestOptions, ) => Promise>; + onCouponUpdate?: ( + id: string, + data: Record, + options?: ProviderRequestOptions, + ) => Promise>; + onCouponDelete?: ( + id: string, + options?: ProviderRequestOptions, + ) => Promise>; } = {}) { return defineProvider({ id: 'stub', @@ -1668,31 +1733,35 @@ function createStubProvider({ next: null, }), update: async (_id, _data, options) => - withRaw( - { - id: 'promo_123', - provider: 'stub', - sandbox, - code: 'WELCOME10', - status: 'active' as const, - discount: { type: 'percentage' as const, value: 10 }, - redemptions: { count: 0, max: null }, - }, - { id: 'raw_coupon_123' }, - options?.includeRaw, - ), - delete: async (_id, options) => - withRaw( - { - id: 'promo_123', - provider: 'stub', - sandbox, - deleted: true, - code: 'WELCOME10', - }, - { id: 'raw_coupon_123' }, - options?.includeRaw, - ), + onCouponUpdate + ? onCouponUpdate(_id, _data as Record, options) + : withRaw( + { + id: 'promo_123', + provider: 'stub', + sandbox, + code: 'WELCOME10', + status: 'active' as const, + discount: { type: 'percentage' as const, value: 10 }, + redemptions: { count: 0, max: null }, + }, + { id: 'raw_coupon_123' }, + options?.includeRaw, + ), + delete: onCouponDelete + ? onCouponDelete + : async (_id, options) => + withRaw( + { + id: 'promo_123', + provider: 'stub', + sandbox, + deleted: true, + code: 'WELCOME10', + }, + { id: 'raw_coupon_123' }, + options?.includeRaw, + ), check: onCouponCheck ? onCouponCheck : async (_data, options) => ({ From 42799e147f750aa05beebca65a2fc3cf381dfcb0 Mon Sep 17 00:00:00 2001 From: almeida Date: Mon, 15 Jun 2026 09:05:01 -0300 Subject: [PATCH 13/15] feat(coupons): add support to hooks events --- apps/web/content/docs/concepts/api.mdx | 12 + apps/web/content/docs/concepts/coupons.mdx | 156 +++++++++- packages/abacatepay/src/shared/constants.ts | 2 +- packages/cli/README.md | 2 +- packages/cli/src/commands/trigger.ts | 5 +- packages/cli/test/cli.test.ts | 20 +- packages/dodo/src/shared/constants.ts | 2 +- packages/paymesh/src/client/coupons.ts | 68 +++-- packages/paymesh/src/database/webhooks.ts | 136 ++++++++- packages/paymesh/src/plugins/runtime.ts | 7 + packages/paymesh/src/types/client.ts | 42 ++- packages/paymesh/src/types/providers.ts | 13 + packages/paymesh/test/client.test.ts | 102 ++++++- packages/paymesh/test/database.test.ts | 314 +++++++++++++++++++- 14 files changed, 828 insertions(+), 53 deletions(-) diff --git a/apps/web/content/docs/concepts/api.mdx b/apps/web/content/docs/concepts/api.mdx index 62080fb..ab8775d 100644 --- a/apps/web/content/docs/concepts/api.mdx +++ b/apps/web/content/docs/concepts/api.mdx @@ -79,6 +79,11 @@ Providers that advertise `coupons` expose: - `coupons.archive()` - `coupons.check()` +Coupon lifecycle helpers now have distinct semantics: + +- `archive()` keeps the coupon mapped locally and marks it unusable +- `delete()` removes the coupon from the provider when supported and marks the local mapping as deleted + ## Capability model Providers declare capabilities instead of pretending every provider is equivalent. @@ -105,6 +110,13 @@ Webhook delivery is normalized into Paymesh event types such as: - `payment.failed` - `payment.canceled` - `payment.refunded` +- `coupon.created` +- `coupon.updated` +- `coupon.deleted` +- `coupon.archived` +- `coupon.redeemed` +- `coupon.redemption_failed` +- `coupon.expired` - `customer.created` - `customer.updated` - `customer.deleted` diff --git a/apps/web/content/docs/concepts/coupons.mdx b/apps/web/content/docs/concepts/coupons.mdx index 8cea1c2..62c21f9 100644 --- a/apps/web/content/docs/concepts/coupons.mdx +++ b/apps/web/content/docs/concepts/coupons.mdx @@ -74,7 +74,13 @@ type Coupon = { sandbox: boolean; code: string; name?: string; - status: "active" | "inactive" | "scheduled" | "expired" | "deleted"; + status: + | "active" + | "inactive" + | "archived" + | "scheduled" + | "expired" + | "deleted"; active?: boolean; discount: | { @@ -224,15 +230,30 @@ Current helper semantics: - `activate(id)` delegates to `coupons.update(id, { active: true })` - `deactivate(id)` delegates to `coupons.update(id, { active: false })` -- `archive(id)` delegates to `coupons.delete(id)` +- `archive(id)` archives the coupon and returns a normalized `Coupon` That means: -- `activate()` and `deactivate()` return a normalized `Coupon` -- `archive()` returns the same normalized delete result as `coupons.delete()` +- `activate()`, `deactivate()`, and `archive()` return a normalized `Coupon` +- `archive()` is intentionally different from `delete()` +- `archive()` keeps the coupon mapped in Paymesh, but marks it unusable +- `delete()` removes the provider-side coupon object when the provider supports hard deletion and marks the local mapping as deleted Use these helpers when your app wants explicit intent without hand-writing tiny update payloads. +### `archive()` vs `delete()` + +Use `archive()` when you want to stop future use of a coupon but keep a stable local record and history for it. + +Use `delete()` when you want Paymesh to remove the coupon from the provider and mark the local mapping as deleted. + +Current provider notes: + +- Stripe archives by deactivating the promotion code and normalizing the result as `status: "archived"` +- Stripe deletes by deleting the underlying Stripe coupon and marking the Paymesh mapping as deleted +- Polar archives by setting the discount inactive and normalizing the result as `status: "archived"` +- Polar deletes through the Polar discount delete endpoint + ## Deleting coupons ```ts title="delete.ts" @@ -398,6 +419,133 @@ The built-in schema now includes a `coupons` table. Paymesh stores normalized co - `data` - `raw` +Archived coupons stay in the local `coupons` table with `status: "archived"`. Deleted coupons are marked as deleted in the local mapping. + +## Coupon hooks + +When a provider webhook is normalized through `paymesh.webhooks.handle()`, Paymesh can dispatch coupon lifecycle hooks: + +- `onCouponCreated` +- `onCouponUpdated` +- `onCouponDeleted` +- `onCouponArchived` +- `onCouponRedeemed` +- `onCouponRedemptionFailed` +- `onCouponExpired` + +Example: + +```ts title="hooks.ts" +import { createClient } from "paymesh"; +import { stripe } from "@paymesh/stripe"; + +export const paymesh = createClient({ + provider: stripe({ + secret: "sk_test_123", + webhookSecret: "whsec_123", + }), + hooks: { + onCouponRedeemed: async (event) => { + console.log("coupon redeemed", { + id: event.data.id, + code: event.data.code, + count: event.data.redemptions.count, + }); + }, + onCouponArchived: async (event) => { + console.log("coupon archived", event.data.code); + }, + }, +}); +``` + +### Normalized coupon event names + +The normalized event types are: + +- `coupon.created` +- `coupon.updated` +- `coupon.deleted` +- `coupon.archived` +- `coupon.redeemed` +- `coupon.redemption_failed` +- `coupon.expired` + +### Event payloads + +These hooks receive the same normalized event envelope as other Paymesh webhook hooks: + +```ts title="coupon-event.ts" +{ + id: "evt_123", + type: "coupon.redeemed", + provider: "stripe", + sandbox: true, + data: { + id: "promo_abc", + code: "WELCOME10", + status: "active", + redemptions: { + count: 1, + max: null, + }, + }, + context: { + deliveryId: "evt_123", + dispatchedAt: "2026-06-13T12:00:00.000Z", + request: Request, + hook: "onCouponRedeemed", + }, +} +``` + +`coupon.deleted` is the exception. It uses the normalized coupon delete payload: + +```ts title="coupon-deleted-event.ts" +{ + id: "evt_456", + type: "coupon.deleted", + data: { + id: "promo_abc", + provider: "stripe", + sandbox: true, + deleted: true, + code: "WELCOME10", + }, +} +``` + +### How Paymesh decides between `updated`, `archived`, `redeemed`, and `expired` + +Some providers only emit a generic coupon update webhook. + +When Paymesh has a database configured, it compares the incoming coupon state with the previously persisted coupon and upgrades the normalized event when possible: + +- if the coupon becomes unusable by archive semantics, Paymesh emits `coupon.archived` +- if the coupon transitions into expiration, Paymesh emits `coupon.expired` +- if the redemption counter increases, Paymesh emits `coupon.redeemed` +- otherwise, Paymesh emits `coupon.updated` + +Without a configured database, Paymesh cannot compare previous coupon state, so those provider updates stay as `coupon.updated`. + +### Provider webhook support + +Current support in this repository: + +| Provider | Coupon webhook mapping | +| --- | --- | +| Stripe | Supports coupon creation and update webhook normalization from Stripe promotion code events. With a configured Paymesh database, generic Stripe updates can be upgraded to `coupon.archived`, `coupon.redeemed`, or `coupon.expired` based on previous stored state. | +| Polar | No native coupon webhook mapping in the current provider package. | + +### Notes on `coupon.redemption_failed` + +The hook and normalized event type exist in the public API, but they only fire when a provider exposes a failure webhook that can be mapped safely. + +In the current repository: + +- Stripe does not currently expose a mapped native coupon redemption failure event through the Paymesh provider package +- Polar does not currently expose coupon webhook events through the Paymesh provider package + ## Provider-specific behavior ### Stripe diff --git a/packages/abacatepay/src/shared/constants.ts b/packages/abacatepay/src/shared/constants.ts index 336acb8..93dd39f 100644 --- a/packages/abacatepay/src/shared/constants.ts +++ b/packages/abacatepay/src/shared/constants.ts @@ -35,7 +35,7 @@ export const ABACATEPAY_EVENTS: Record = { 'subscription.trial_started': 'subscription.created', }; -export const ABACATEPAY_HOOKS: Record = { +export const ABACATEPAY_HOOKS: Partial> = { 'payment.created': 'onPaymentCreated', 'payment.succeeded': 'onPaymentSucceeded', 'payment.failed': 'onPaymentFailed', diff --git a/packages/cli/README.md b/packages/cli/README.md index 62a5791..7d342eb 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -80,7 +80,7 @@ paymesh trigger customer.created --client ./src/lib/paymesh.ts --data '{"email": paymesh trigger payment.succeeded --client ./src/lib/paymesh.ts --listen http://127.0.0.1:3000/webhooks # sends the built-in event to a running paymesh listen server instead of calling local hooks directly -paymesh trigger onCouponRedeemed --client ./src/lib/paymesh.ts --data '{"code":"WELCOME10"}' +paymesh trigger onCouponRedeemedCustom --client ./src/lib/paymesh.ts --data '{"code":"WELCOME10"}' # emits a registered plugin event; plugin events require --data ``` diff --git a/packages/cli/src/commands/trigger.ts b/packages/cli/src/commands/trigger.ts index 6305233..2804bdb 100644 --- a/packages/cli/src/commands/trigger.ts +++ b/packages/cli/src/commands/trigger.ts @@ -32,7 +32,7 @@ const BUILT_IN_HOOKS = { 'subscription.updated': 'onSubscriptionUpdated', 'subscription.canceled': 'onSubscriptionCanceled', 'checkout.completed': 'onCheckoutCompleted', -} satisfies Record; +} satisfies Partial>; export function registerTriggerCommand(program: Command) { program @@ -72,7 +72,8 @@ export function registerTriggerCommand(program: Command) { if (eventName in BUILT_IN_HOOKS) { const data = rawData ? parseObjectJson(rawData, source) : {}; - const hook = BUILT_IN_HOOKS[eventName as PaymeshEventType]; + const hook = + BUILT_IN_HOOKS[eventName as keyof typeof BUILT_IN_HOOKS]!; const event = { id: `evt_trigger_${randomUUID()}`, diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 6a21ca1..6e6bddf 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -217,7 +217,7 @@ describe('cli helpers', () => { 'node', 'paymesh', 'trigger', - 'onCouponRedeemed', + 'onCouponRedeemedCustom', '--client', './paymesh-client.ts', '--data', @@ -226,9 +226,9 @@ describe('cli helpers', () => { }), ); - expect(logs).toContain('onCouponRedeemed'); + expect(logs).toContain('onCouponRedeemedCustom'); expect(logs).toContain('plugin coupons'); - expect(logs).toContain('hook onCouponRedeemed'); + expect(logs).toContain('hook onCouponRedeemedCustom'); expect( await fs.readFile(path.join(directory, 'trigger-log.json'), 'utf8'), ).toContain('"code":"WELCOME10"'); @@ -276,7 +276,7 @@ describe('cli helpers', () => { 'node', 'paymesh', 'trigger', - 'onCouponRedeemed', + 'onCouponRedeemedCustom', '--client', './paymesh-client.ts', '--data', @@ -435,7 +435,7 @@ describe('cli helpers', () => { 'node', 'paymesh', 'trigger', - 'onCouponRedeemed', + 'onCouponRedeemedCustom', '--client', './paymesh-client.ts', ]), @@ -443,7 +443,7 @@ describe('cli helpers', () => { ).rejects.toMatchObject({ code: 'client_error', message: - 'Plugin event "onCouponRedeemed" requires --data or stdin with a JSON payload', + 'Plugin event "onCouponRedeemedCustom" requires --data or stdin with a JSON payload', }); }); @@ -457,7 +457,7 @@ describe('cli helpers', () => { 'node', 'paymesh', 'trigger', - 'onCouponRedeemed', + 'onCouponRedeemedCustom', '--client', './paymesh-client.ts', '--listen', @@ -1454,7 +1454,7 @@ import { createClient, definePlugin, defineProvider } from ${JSON.stringify(paym const coupons = definePlugin({ id: 'coupons', events: { - onCouponRedeemed: { + onCouponRedeemedCustom: { description: 'Triggered when a coupon is redeemed', }, }, @@ -1500,9 +1500,9 @@ ${exportStatement} email: event.data.email, }) + "\\n"); }, - onCouponRedeemed(event) { + onCouponRedeemedCustom(event) { appendFileSync(${JSON.stringify(logFile)}, JSON.stringify({ - hook: 'onCouponRedeemed', + hook: 'onCouponRedeemedCustom', code: event.data.code, }) + "\\n"); }, diff --git a/packages/dodo/src/shared/constants.ts b/packages/dodo/src/shared/constants.ts index 7573a22..2ad15c4 100644 --- a/packages/dodo/src/shared/constants.ts +++ b/packages/dodo/src/shared/constants.ts @@ -54,7 +54,7 @@ export const DODO_EVENTS: Record = { 'subscription.updated': 'subscription.updated', } satisfies Record; -export const DODO_HOOKS: Record = { +export const DODO_HOOKS: Partial> = { 'payment.created': 'onPaymentCreated', 'payment.succeeded': 'onPaymentSucceeded', 'payment.failed': 'onPaymentFailed', diff --git a/packages/paymesh/src/client/coupons.ts b/packages/paymesh/src/client/coupons.ts index 50bfcfa..8e39dd6 100644 --- a/packages/paymesh/src/client/coupons.ts +++ b/packages/paymesh/src/client/coupons.ts @@ -40,7 +40,8 @@ export function createCouponsClient< provider: P; schema: ResolvedDatabaseSchema; }) { - const couponsRepository = database?.repositories.coupons; + const { coupons } = database?.repositories ?? {}; + const getProviderCoupons = () => getRequiredProviderFeature(provider.coupons, provider.id, 'coupons'); @@ -50,24 +51,25 @@ export function createCouponsClient< requestOptions?: ProviderRequestOptions, ) => { assertCapability('coupons'); + const providerCoupons = getProviderCoupons(); const { input, extra } = splitExtraFields( data, schema.tables.coupons.fields, ); + const coupon = await providerCoupons.create( input as Parameters[0], mergeOptions(requestOptions), ); + const resolvedCoupon = Object.assign(coupon, extra) as PaymeshCoupon< CallIncludeRaw, Schema >; - if (couponsRepository) { - await couponsRepository.upsert(schema, resolvedCoupon); - } + if (coupons) await coupons.upsert(schema, resolvedCoupon); return resolvedCoupon; }, @@ -76,11 +78,12 @@ export function createCouponsClient< requestOptions?: ProviderRequestOptions, ) => { assertCapability('coupons'); + const providerCoupons = getProviderCoupons(); const mergedOptions = mergeOptions(requestOptions); - if (couponsRepository) { - const coupon = await couponsRepository.findByProviderId( + if (coupons) { + const coupon = await coupons.findByProviderId( schema, provider.id, mergedOptions.sandbox ?? provider.isSandbox(), @@ -90,9 +93,7 @@ export function createCouponsClient< }, ); - if (coupon) { - return coupon as PaymeshCoupon; - } + if (coupon) return coupon as PaymeshCoupon; } return providerCoupons.get(id, mergedOptions) as Promise< @@ -103,10 +104,11 @@ export function createCouponsClient< options?: PaymeshCouponListOptions, ) => { assertCapability('coupons'); + const providerCoupons = getProviderCoupons(); - if (couponsRepository) { - const result = await couponsRepository.list( + if (coupons) { + const result = await coupons.list( schema, provider.id, options?.sandbox ?? provider.isSandbox(), @@ -152,25 +154,26 @@ export function createCouponsClient< requestOptions?: ProviderRequestOptions, ) => { assertCapability('coupons'); + const providerCoupons = getProviderCoupons(); const { input, extra } = splitExtraFields( data, schema.tables.coupons.fields, ); + const coupon = await providerCoupons.update( id, input as Parameters[1], mergeOptions(requestOptions), ); + const resolvedCoupon = Object.assign(coupon, extra) as PaymeshCoupon< CallIncludeRaw, Schema >; - if (couponsRepository) { - await couponsRepository.upsert(schema, resolvedCoupon); - } + if (coupons) await coupons.upsert(schema, resolvedCoupon); return resolvedCoupon; }, @@ -197,39 +200,62 @@ export function createCouponsClient< requestOptions?: ProviderRequestOptions, ) => { assertCapability('coupons'); + const providerCoupons = getProviderCoupons(); + const result = await providerCoupons.delete( id, mergeOptions(requestOptions), ); - if (couponsRepository) { - await couponsRepository.markDeleted(schema, result); - } + if (coupons) await coupons.markDeleted(schema, result); return result; }, archive: async ( id: string, requestOptions?: ProviderRequestOptions, - ) => client.delete(id, requestOptions), + ) => { + assertCapability('coupons'); + + const providerCoupons = getProviderCoupons(); + + const archivedCoupon = providerCoupons.archive + ? await providerCoupons.archive(id, mergeOptions(requestOptions)) + : await providerCoupons.update( + id, + { active: false }, + mergeOptions(requestOptions), + ); + + const resolvedCoupon = { + ...archivedCoupon, + status: 'archived', + active: false, + } as PaymeshCoupon; + + if (coupons) await coupons.upsert(schema, resolvedCoupon); + + return resolvedCoupon; + }, check: async ( data: CouponCheckData, requestOptions?: ProviderRequestOptions, ) => { assertCapability('coupons'); + const providerCoupons = getProviderCoupons(); + const result = await providerCoupons.check( data, mergeOptions(requestOptions), ); - if (couponsRepository && result.coupon) { - await couponsRepository.upsert( + if (coupons && result.coupon) + await coupons.upsert( schema, result.coupon as PaymeshCoupon, ); - } return result as PaymeshCouponCheckResult; }, diff --git a/packages/paymesh/src/database/webhooks.ts b/packages/paymesh/src/database/webhooks.ts index 73779fd..f5e4f04 100644 --- a/packages/paymesh/src/database/webhooks.ts +++ b/packages/paymesh/src/database/webhooks.ts @@ -4,7 +4,12 @@ import type { PaymeshDatabaseDriver, ResolvedDatabaseSchema, } from '../types/database'; -import type { AnyPayment, PaymeshEvent, Provider } from '../types/providers'; +import type { + AnyPayment, + Coupon, + PaymeshEvent, + Provider, +} from '../types/providers'; interface HandleClientWebhookOptions { provider: Provider; @@ -65,7 +70,14 @@ export async function handleClientWebhook({ }; } - const event = handled.event as PaymeshEvent; + 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 951c251..3657253 100644 --- a/packages/paymesh/src/types/client.ts +++ b/packages/paymesh/src/types/client.ts @@ -64,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, @@ -96,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 @@ -123,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> >; @@ -424,7 +464,7 @@ export interface PaymeshCouponsClient< archive( id: string, options?: ProviderRequestOptions, - ): Promise>; + ): Promise>; /** Validates a coupon code against the provided cart context. */ check( data: CouponCheckData, diff --git a/packages/paymesh/src/types/providers.ts b/packages/paymesh/src/types/providers.ts index 0c16fea..92e408b 100644 --- a/packages/paymesh/src/types/providers.ts +++ b/packages/paymesh/src/types/providers.ts @@ -213,6 +213,7 @@ export type CustomerDeleteResult = WithRaw< export type CouponStatus = | 'active' | 'inactive' + | 'archived' | 'scheduled' | 'expired' | 'deleted'; @@ -593,6 +594,11 @@ export interface ProviderCoupons { 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, @@ -617,6 +623,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' diff --git a/packages/paymesh/test/client.test.ts b/packages/paymesh/test/client.test.ts index 85548fc..c467585 100644 --- a/packages/paymesh/test/client.test.ts +++ b/packages/paymesh/test/client.test.ts @@ -192,6 +192,7 @@ describe('client', () => { test('provides coupon lifecycle helpers', async () => { const updates: Array = []; + const archives: string[] = []; const deletes: string[] = []; const client = createClient({ provider: createStubProvider({ @@ -215,6 +216,23 @@ describe('client', () => { 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( @@ -235,12 +253,16 @@ describe('client', () => { 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.deleted).toBe(true); + expect(archived.status).toBe('archived'); + expect(archived.active).toBe(false); + expect(deleted.deleted).toBe(true); }); test('delegates client.isSandbox() to the provider', () => { @@ -830,6 +852,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(client); + }); + test('narrows payment webhook payloads to PIX when method is pix', () => { const client = createClient({ provider: createStubProvider(), @@ -1347,7 +1413,7 @@ describe('client', () => { const plugin = definePlugin({ id: 'coupons', events: { - onCouponRedeemed: event<{ code: string }>({ + onCouponRedeemedCustom: event<{ code: string }>({ description: 'Triggered when a coupon is redeemed', example: { code: 'WELCOME10', @@ -1356,7 +1422,7 @@ describe('client', () => { }), }, hooks: { - onCouponRedeemed(event) { + onCouponRedeemedCustom(event) { expectType(event.data.code); pluginHooks.push(event.data.code); }, @@ -1383,7 +1449,7 @@ describe('client', () => { expectType( context.client.customers.get, ); - await context.emit('onCouponRedeemed', { + await context.emit('onCouponRedeemedCustom', { code: 'WELCOME10', }); @@ -1401,7 +1467,7 @@ describe('client', () => { return { repository: 'available', async redeem(code: string) { - await context.emit('onCouponRedeemed', { + await context.emit('onCouponRedeemedCustom', { code, }); @@ -1423,7 +1489,7 @@ describe('client', () => { provider: createStubProvider(), plugins: [plugin] as const, hooks: { - onCouponRedeemed(event) { + onCouponRedeemedCustom(event) { expectType(event.data.code); // @ts-expect-error plugin event payload should not accept unknown properties expectType(event.data.missing); @@ -1448,7 +1514,7 @@ describe('client', () => { }, ]); expect(client.plugins.byId.coupons?.eventHooks).toEqual([ - 'onCouponRedeemed', + 'onCouponRedeemedCustom', ]); const response = await client.routes.handle( @@ -1601,6 +1667,7 @@ function createStubProvider({ onCouponGet, onCouponCheck, onCouponUpdate, + onCouponArchive, onCouponDelete, }: { sandbox?: boolean; @@ -1646,6 +1713,10 @@ function createStubProvider({ data: Record, options?: ProviderRequestOptions, ) => Promise>; + onCouponArchive?: ( + id: string, + options?: ProviderRequestOptions, + ) => Promise>; onCouponDelete?: ( id: string, options?: ProviderRequestOptions, @@ -1748,6 +1819,23 @@ function createStubProvider({ { id: 'raw_coupon_123' }, options?.includeRaw, ), + archive: onCouponArchive + ? onCouponArchive + : async (_id, options) => + withRaw( + { + id: 'promo_123', + provider: 'stub', + sandbox, + code: 'WELCOME10', + status: 'archived' as const, + active: false, + discount: { type: 'percentage' as const, value: 10 }, + redemptions: { count: 0, max: null }, + }, + { id: 'raw_coupon_123' }, + options?.includeRaw, + ), delete: onCouponDelete ? onCouponDelete : async (_id, options) => diff --git a/packages/paymesh/test/database.test.ts b/packages/paymesh/test/database.test.ts index f8b3324..0accf92 100644 --- a/packages/paymesh/test/database.test.ts +++ b/packages/paymesh/test/database.test.ts @@ -589,6 +589,237 @@ describe('database support', () => { expect(calls).toEqual(['specific:cus_123']); }); + test('dispatches coupon lifecycle hooks after normalizing provider updates', async () => { + const database = createMockDatabase(); + const calls: string[] = []; + database.seedCoupon({ + id: 'promo_123', + provider: 'stub', + sandbox: false, + code: 'WELCOME10', + status: 'active', + active: true, + discount: { + type: 'percentage', + value: 10, + }, + redemptions: { + count: 0, + max: null, + }, + createdAt: '2026-06-01T00:00:00.000Z', + raw: null, + }); + const provider = defineProvider({ + id: 'stub', + isSandbox: () => false, + capabilities: { + checkout: true, + coupons: true, + webhooks: true, + }, + payments: { + create: async (_data, options) => + withRaw( + { + id: 'chk_123', + provider: 'stub', + sandbox: false, + amount: 1000, + currency: 'usd', + status: 'pending' as const, + }, + { id: 'raw_checkout' }, + options?.includeRaw, + ), + }, + customers: { + get: async (_id, options) => + withRaw( + { + id: 'cus_123', + provider: 'stub', + sandbox: false, + }, + { id: 'raw_customer' }, + options?.includeRaw, + ), + upsert: async (_data, options) => + withRaw( + { + id: 'cus_123', + provider: 'stub', + sandbox: false, + }, + { id: 'raw_customer' }, + options?.includeRaw, + ), + delete: async (_id, options) => + withRaw( + { + id: 'cus_123', + provider: 'stub', + sandbox: false, + deleted: true, + }, + { id: 'raw_customer' }, + options?.includeRaw, + ), + }, + coupons: { + create: async (_data, options) => + withRaw( + { + id: 'promo_123', + provider: 'stub', + sandbox: false, + code: 'WELCOME10', + status: 'active' as const, + discount: { type: 'percentage' as const, value: 10 }, + redemptions: { count: 0, max: null }, + }, + { id: 'raw_coupon' }, + options?.includeRaw, + ), + get: async (_id, options) => + withRaw( + { + id: 'promo_123', + provider: 'stub', + sandbox: false, + code: 'WELCOME10', + status: 'active' as const, + discount: { type: 'percentage' as const, value: 10 }, + redemptions: { count: 0, max: null }, + }, + { id: 'raw_coupon' }, + options?.includeRaw, + ), + list: async (_listOptions, options) => ({ + data: [ + withRaw( + { + id: 'promo_123', + provider: 'stub', + sandbox: false, + code: 'WELCOME10', + status: 'active' as const, + discount: { type: 'percentage' as const, value: 10 }, + redemptions: { count: 0, max: null }, + }, + { id: 'raw_coupon' }, + options?.includeRaw, + ), + ], + total: 1, + previous: null, + next: null, + }), + update: async (_id, _data, options) => + withRaw( + { + id: 'promo_123', + provider: 'stub', + sandbox: false, + code: 'WELCOME10', + status: 'active' as const, + discount: { type: 'percentage' as const, value: 10 }, + redemptions: { count: 1, max: null }, + }, + { id: 'raw_coupon' }, + options?.includeRaw, + ), + delete: async (_id, options) => + withRaw( + { + id: 'promo_123', + provider: 'stub', + sandbox: false, + deleted: true, + code: 'WELCOME10', + }, + { id: 'raw_coupon' }, + options?.includeRaw, + ), + check: async (_data, options) => ({ + valid: true as const, + coupon: withRaw( + { + id: 'promo_123', + provider: 'stub', + sandbox: false, + code: 'WELCOME10', + status: 'active' as const, + discount: { type: 'percentage' as const, value: 10 }, + redemptions: { count: 1, max: null }, + }, + { id: 'raw_coupon' }, + options?.includeRaw, + ), + }), + }, + webhooks: { + verify: async () => true, + handle: async ({ request, includeRaw }) => { + const payload = (await request.json()) as Record; + + return { + deliveryId: 'evt_coupon_redeemed', + hook: 'onCouponUpdated', + event: withRaw( + { + id: 'evt_coupon_redeemed', + type: 'coupon.updated' as const, + provider: 'stub', + sandbox: false, + data: withRaw( + { + id: 'promo_123', + provider: 'stub', + sandbox: false, + code: 'WELCOME10', + status: 'active' as const, + discount: { type: 'percentage' as const, value: 10 }, + redemptions: { count: 1, max: null }, + }, + payload, + includeRaw, + ), + }, + payload, + includeRaw, + ), + }; + }, + }, + }); + const client = createClient({ + provider, + database, + }); + + const result = await client.webhooks.handle({ + request: new Request('https://app.test/webhooks', { + method: 'POST', + body: JSON.stringify({ code: 'WELCOME10' }), + headers: { + 'content-type': 'application/json', + }, + }), + hooks: { + onCouponUpdated: async () => { + calls.push('updated'); + }, + onCouponRedeemed: async (event) => { + calls.push(`redeemed:${event.data.redemptions.count}`); + }, + }, + }); + + expect(result.status).toBe(200); + expect(calls).toEqual(['redeemed:1']); + }); + test('dispatches onEvent when there is no specific hook handler', async () => { const database = createMockDatabase(); const calls: string[] = []; @@ -1077,6 +1308,18 @@ function createMockDatabase({ persistRaw?: boolean; } = {}) { const webhookEvents = new Map(); + const coupons = new Map< + string, + Record & { + id: string; + provider: string; + sandbox: boolean; + code: string; + createdAt: string; + deleted?: boolean; + raw: unknown; + } + >(); const customers = new Map< string, Record & { @@ -1095,6 +1338,61 @@ function createMockDatabase({ dialect: 'postgres', persistRaw, repositories: { + coupons: { + async findByProviderId(_schema, provider, sandbox, id, options) { + const coupon = coupons.get(`${provider}:${sandbox}:${id}`); + if (!coupon || coupon.deleted) return null; + + const { + createdAt: _createdAt, + deleted: _deleted, + raw, + ...data + } = coupon; + return withRaw( + { + ...data, + }, + raw, + options?.includeRaw, + ) as never; + }, + async upsert(_schema, coupon) { + const key = `${coupon.provider}:${coupon.sandbox}:${coupon.id}`; + const existing = coupons.get(key); + const createdAt = existing?.createdAt ?? new Date().toISOString(); + coupons.set(key, { + id: coupon.id, + provider: coupon.provider, + sandbox: coupon.sandbox, + code: coupon.code, + createdAt, + ...(coupon as Record), + raw: persistRaw ? getInternalRaw(coupon) : null, + }); + }, + async list() { + return { + data: [], + total: 0, + previous: null, + next: null, + }; + }, + async markDeleted(_schema, coupon) { + const key = `${coupon.provider}:${coupon.sandbox}:${coupon.id}`; + const existing = coupons.get(key); + coupons.set(key, { + id: coupon.id, + provider: coupon.provider, + sandbox: coupon.sandbox, + code: coupon.code ?? existing?.code ?? '', + createdAt: existing?.createdAt ?? new Date().toISOString(), + deleted: true, + raw: persistRaw ? getInternalRaw(coupon) : null, + }); + }, + }, customers: { async findByProviderId(_schema, provider, sandbox, id, options) { const customer = customers.get(`${provider}:${sandbox}:${id}`); @@ -1308,7 +1606,21 @@ function createMockDatabase({ ); } - return Object.assign(database, { customerWrites, seedCustomer }); + function seedCoupon( + coupon: Record & { + id: string; + provider: string; + sandbox: boolean; + code: string; + createdAt: string; + deleted?: boolean; + raw: unknown; + }, + ) { + coupons.set(`${coupon.provider}:${coupon.sandbox}:${coupon.id}`, coupon); + } + + return Object.assign(database, { customerWrites, seedCoupon, seedCustomer }); } function resolveCustomerListLimit(limit?: number) { From 07b4ee71ce8c9198433016799d3f92abbc71cb26 Mon Sep 17 00:00:00 2001 From: almeida Date: Mon, 15 Jun 2026 10:06:10 -0300 Subject: [PATCH 14/15] feat(coupons): add support to stripe and polar --- packages/paymesh/src/types/providers.ts | 8 +- packages/polar/src/index.ts | 438 +++++++++++++--- packages/polar/src/shared/constants.ts | 50 +- packages/polar/src/shared/mapper.ts | 103 +++- packages/polar/src/shared/sync.ts | 2 +- packages/polar/src/shared/webhooks.ts | 279 +++++------ packages/polar/src/types.ts | 30 ++ packages/polar/test/polar.test.ts | 190 +++++++ packages/stripe/src/index.ts | 633 +++++++++++++++++++----- packages/stripe/src/shared/constants.ts | 9 +- packages/stripe/src/shared/coupons.ts | 284 +++++++++++ packages/stripe/src/shared/mapper.ts | 277 ++++++++--- packages/stripe/src/shared/sync.ts | 2 +- packages/stripe/src/shared/webhooks.ts | 123 +++++ packages/stripe/src/types.ts | 60 ++- packages/stripe/test/stripe.test.ts | 299 +++++++++++ 16 files changed, 2337 insertions(+), 450 deletions(-) create mode 100644 packages/stripe/src/shared/coupons.ts create mode 100644 packages/stripe/src/shared/webhooks.ts diff --git a/packages/paymesh/src/types/providers.ts b/packages/paymesh/src/types/providers.ts index 92e408b..c66922c 100644 --- a/packages/paymesh/src/types/providers.ts +++ b/packages/paymesh/src/types/providers.ts @@ -505,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. */ @@ -526,7 +532,7 @@ export interface ProviderDashboardAdapter { /** Synchronizes a subscription or equivalent resource. */ syncSubscription?( input: ProviderDashboardSyncInput, - ): Promise | null>; + ): Promise; } /** Payment creation contract. */ diff --git a/packages/polar/src/index.ts b/packages/polar/src/index.ts index 023e330..41761e9 100644 --- a/packages/polar/src/index.ts +++ b/packages/polar/src/index.ts @@ -1,33 +1,32 @@ -import { createHmac, timingSafeEqual } from 'node:crypto'; import { + type BaseCouponDeleteResult, + type CouponCheckData, + type CouponCheckResult, + type CouponCreateData, + type CouponListOptions, + type CouponListResult, + type CouponUpdateData, type CustomerUpsertData, defineProvider, type Payment, type PaymentCreateData, PaymeshError, - type PaymeshEvent, type ProviderRequestOptions, - type ProviderWebhookHandleOptions, - type ProviderWebhookHandleResult, request, withRaw, } from 'paymesh'; import { readPolarProducts, readVersion } from './shared/catalog'; import { POLAR_BASE_URL, POLAR_CAPABILITIES } from './shared/constants'; -import { mapPolarCustomer } from './shared/mapper'; +import { mapPolarCustomer, mapPolarDiscount } from './shared/mapper'; import { syncPolarPayment, syncPolarSubscription } from './shared/sync'; -import { - resolvePolarWebhookData, - resolvePolarWebhookHook, - resolvePolarWebhookId, - resolvePolarWebhookType, -} from './shared/webhooks'; +import { handlePolarWebhook, verifyPolarWebhook } from './shared/webhooks'; import type { PolarCheckout, PolarCustomer, + PolarDiscount, + PolarDiscountListResponse, PolarProductListResponse, PolarProviderOptions, - PolarWebhookEvent, } from './types'; export type * from './types'; @@ -45,6 +44,7 @@ export type * from './types'; */ export const polar = ({ accessToken = process.env.POLAR_ACCESS_TOKEN, + organizationId, webhookSecret = process.env.POLAR_WEBHOOK_SECRET, baseUrl = POLAR_BASE_URL, sandbox, @@ -79,6 +79,23 @@ export const polar = ({ headers: baseRequestOptions.headers, }); + const listPolarDiscounts = async ( + query: URLSearchParams, + options?: ProviderRequestOptions, + ) => + request( + `/v1/discounts${query.size > 0 ? `?${query.toString()}` : ''}`, + { + provider: 'polar', + ...resolveRequestOptions(options), + method: 'GET', + }, + ).then((response) => + Array.isArray(response) + ? response + : (response.items ?? response.data ?? []), + ); + return defineProvider({ id: 'polar', isSandbox: resolveProviderSandbox, @@ -88,14 +105,13 @@ export const polar = ({ data: PaymentCreateData, options?: ProviderRequestOptions, ): Promise> { - if (!data.productIds || data.productIds.length === 0) { + if (!data.productIds || data.productIds.length === 0) throw new PaymeshError({ code: 'invalid_request', message: 'Provider "polar" requires at least one product id in "productIds"', provider: 'polar', }); - } if (!data.currency) throw new PaymeshError({ @@ -109,6 +125,21 @@ export const polar = ({ ([, value]) => value !== null, ), ); + const resolvedDiscountId = data.couponCode + ? ( + await listPolarDiscounts( + new URLSearchParams({ code: data.couponCode, limit: '1' }), + options, + ) + )[0]?.id + : undefined; + if (data.couponCode && !resolvedDiscountId) { + throw new PaymeshError({ + code: 'provider_not_found', + message: `Polar coupon code "${data.couponCode}" was not found.`, + provider: 'polar', + }); + } const checkout = await request('/v1/checkouts', { provider: 'polar', @@ -125,6 +156,8 @@ export const polar = ({ metadata: Object.keys(metadata).length > 0 ? metadata : undefined, success_url: data.successUrl, return_url: data.returnUrl ?? data.cancelUrl, + allow_discount_codes: data.allowCouponCodes || undefined, + discount_id: resolvedDiscountId, }, }); @@ -163,6 +196,312 @@ export const polar = ({ ); }, }, + coupons: { + async create( + data: CouponCreateData, + options?: ProviderRequestOptions, + ) { + if (data.appliesTo?.prices?.length) { + throw new PaymeshError({ + code: 'invalid_request', + message: 'Provider "polar" does not support appliesTo.prices.', + provider: 'polar', + }); + } + const discount = await request('/v1/discounts', { + provider: 'polar', + ...resolveRequestOptions(options), + method: 'POST', + body: { + organization_id: organizationId, + code: data.code, + name: data.name, + percentage: + data.discount.type === 'percentage' + ? data.discount.value + : undefined, + fixed_amount: + data.discount.type === 'fixed' ? data.discount.value : undefined, + currency: + data.discount.type === 'fixed' + ? data.discount.currency?.toLowerCase() + : undefined, + duration: data.duration?.type, + duration_in_months: data.duration?.durationInMonths, + starts_at: + data.startsAt instanceof Date + ? data.startsAt.toISOString() + : data.startsAt, + ends_at: + data.expiresAt instanceof Date + ? data.expiresAt.toISOString() + : data.expiresAt, + max_redemptions: data.maxRedemptions, + products: data.appliesTo?.products, + metadata: data.metadata, + }, + }); + const coupon = mapPolarDiscount(discount, resolveProviderSandbox()); + return withRaw(coupon, discount, options?.includeRaw); + }, + async get( + id: string, + options?: ProviderRequestOptions, + ) { + const discount = await request( + `/v1/discounts/${encodeURIComponent(id)}`, + { + provider: 'polar', + ...resolveRequestOptions(options), + method: 'GET', + }, + ); + return withRaw( + mapPolarDiscount(discount, resolveProviderSandbox()), + discount, + options?.includeRaw, + ); + }, + async list( + listOptions: CouponListOptions | undefined, + options?: ProviderRequestOptions, + ) { + const query = new URLSearchParams(); + if (organizationId) query.set('organization_id', organizationId); + if (typeof listOptions?.limit === 'number') { + query.set('limit', String(listOptions.limit)); + } + if (listOptions?.after) query.set('page', listOptions.after); + if (listOptions?.code) query.set('code', listOptions.code); + if (typeof listOptions?.active === 'boolean') { + query.set('active', String(listOptions.active)); + } + const discounts = await listPolarDiscounts(query, options); + const data = discounts.map((discount) => + withRaw( + mapPolarDiscount(discount, resolveProviderSandbox()), + discount, + options?.includeRaw, + ), + ); + return { + data, + total: data.length, + previous: null, + next: null, + } satisfies CouponListResult; + }, + async update( + id: string, + data: CouponUpdateData, + options?: ProviderRequestOptions, + ) { + if (data.appliesTo?.prices?.length) { + throw new PaymeshError({ + code: 'invalid_request', + message: 'Provider "polar" does not support appliesTo.prices.', + provider: 'polar', + }); + } + const discount = await request( + `/v1/discounts/${encodeURIComponent(id)}`, + { + provider: 'polar', + ...resolveRequestOptions(options), + method: 'PATCH', + body: { + code: data.code, + name: data.name, + active: data.active, + percentage: + data.discount?.type === 'percentage' + ? data.discount.value + : undefined, + fixed_amount: + data.discount?.type === 'fixed' + ? data.discount.value + : undefined, + currency: + data.discount?.type === 'fixed' + ? data.discount.currency?.toLowerCase() + : undefined, + duration: data.duration?.type, + duration_in_months: data.duration?.durationInMonths, + starts_at: + data.startsAt instanceof Date + ? data.startsAt.toISOString() + : (data.startsAt ?? undefined), + ends_at: + data.expiresAt instanceof Date + ? data.expiresAt.toISOString() + : (data.expiresAt ?? undefined), + max_redemptions: data.maxRedemptions, + products: data.appliesTo?.products, + metadata: data.metadata, + }, + }, + ); + return withRaw( + mapPolarDiscount(discount, resolveProviderSandbox()), + discount, + options?.includeRaw, + ); + }, + async archive( + id: string, + options?: ProviderRequestOptions, + ) { + const discount = await request( + `/v1/discounts/${encodeURIComponent(id)}`, + { + provider: 'polar', + ...resolveRequestOptions(options), + method: 'PATCH', + body: { + active: false, + }, + }, + ); + return withRaw( + mapPolarDiscount(discount, resolveProviderSandbox(), 'archived'), + discount, + options?.includeRaw, + ); + }, + async delete( + id: string, + options?: ProviderRequestOptions, + ) { + const discount = await request( + `/v1/discounts/${encodeURIComponent(id)}`, + { + provider: 'polar', + ...resolveRequestOptions(options), + method: 'DELETE', + }, + ); + const result: BaseCouponDeleteResult = { + id, + provider: 'polar', + sandbox: resolveProviderSandbox(), + deleted: true, + code: typeof discount.code === 'string' ? discount.code : undefined, + }; + return withRaw(result, discount, options?.includeRaw); + }, + async check( + data: CouponCheckData, + options?: ProviderRequestOptions, + ): Promise> { + try { + const discount = ( + await listPolarDiscounts( + new URLSearchParams({ + ...(organizationId ? { organization_id: organizationId } : {}), + code: data.code, + limit: '1', + }), + options, + ) + )[0]; + if (!discount) return { valid: false, reason: 'not_found' }; + const coupon = mapPolarDiscount(discount, resolveProviderSandbox()); + const normalizedCoupon = withRaw( + coupon, + discount, + options?.includeRaw, + ); + if (coupon.status === 'scheduled') { + return { + valid: false, + reason: 'not_started', + coupon: normalizedCoupon, + }; + } + if (coupon.status === 'expired') { + return { + valid: false, + reason: 'expired', + coupon: normalizedCoupon, + }; + } + if (coupon.status === 'inactive' || coupon.active === false) { + return { + valid: false, + reason: 'inactive', + coupon: normalizedCoupon, + }; + } + if ( + typeof coupon.redemptions.max === 'number' && + coupon.redemptions.count >= coupon.redemptions.max + ) { + return { + valid: false, + reason: 'max_redemptions_reached', + coupon: normalizedCoupon, + }; + } + if ( + coupon.appliesTo?.products?.length && + data.productIds?.length && + !data.productIds.some((productId) => + coupon.appliesTo?.products?.includes(productId), + ) + ) { + return { + valid: false, + reason: 'product_not_eligible', + coupon: normalizedCoupon, + }; + } + if ( + typeof data.amount !== 'number' || + typeof data.currency !== 'string' + ) { + return { valid: true, coupon: normalizedCoupon }; + } + let discountTotal = 0; + if (coupon.discount.type === 'percentage') { + discountTotal = Math.floor( + (data.amount * coupon.discount.value) / 100, + ); + } else { + if ( + coupon.discount.currency && + coupon.discount.currency !== data.currency.toUpperCase() + ) { + return { + valid: false, + reason: 'currency_not_supported', + coupon: normalizedCoupon, + }; + } + discountTotal = coupon.discount.value; + } + discountTotal = Math.min(data.amount, discountTotal); + return { + valid: true, + coupon: normalizedCoupon, + preview: { + subtotal: data.amount, + discountTotal, + total: data.amount - discountTotal, + currency: data.currency.toUpperCase(), + }, + }; + } catch (error) { + return { + valid: false, + reason: 'provider_error', + message: + error instanceof Error + ? error.message + : 'Polar coupon check failed.', + }; + } + }, + }, customers: { async upsert( data: CustomerUpsertData, @@ -325,75 +664,10 @@ export const polar = ({ }, webhooks: { async verify({ request }) { - if (!webhookSecret) return false; - - const webhookId = request.headers.get('webhook-id'); - const timestamp = request.headers.get('webhook-timestamp'); - const signatureHeader = request.headers.get('webhook-signature'); - - if (!webhookId || !timestamp || !signatureHeader) return false; - - const secret = webhookSecret.startsWith('whsec_') - ? Buffer.from(webhookSecret.slice('whsec_'.length), 'base64') - : Buffer.from(webhookSecret); - const payload = await request.text(); - const actual = createHmac('sha256', secret) - .update(`${webhookId}.${timestamp}.${payload}`) - .digest('base64'); - - for (const part of signatureHeader.split(/\s+/)) { - const [version, expected] = part.trim().split(',', 2); - if (version !== 'v1' || !expected) continue; - if ( - actual.length === expected.length && - timingSafeEqual(Buffer.from(actual), Buffer.from(expected)) - ) { - return true; - } - } - - return false; + return verifyPolarWebhook(request, webhookSecret); }, - async handle( - options: ProviderWebhookHandleOptions, - ): Promise> { - const { request, includeRaw = false } = options; - - const payload = await request.json(); - const webhookId = request.headers.get('webhook-id'); - - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { - throw new TypeError('Polar webhook payload must be a JSON object.'); - } - - const body = payload as Record; - const event = body as unknown as PolarWebhookEvent; - const type = resolvePolarWebhookType(event); - const eventSandbox = resolveProviderSandbox(); - const data = resolvePolarWebhookData( - type, - event, - includeRaw, - eventSandbox, - ); - const id = resolvePolarWebhookId(event); - const hook = resolvePolarWebhookHook(type); - - return { - deliveryId: webhookId ?? undefined, - hook, - event: withRaw( - { - id, - type, - provider: 'polar', - sandbox: eventSandbox, - data, - }, - body, - includeRaw, - ) as PaymeshEvent, - }; + async handle(options) { + return handlePolarWebhook(options, resolveProviderSandbox); }, }, }); diff --git a/packages/polar/src/shared/constants.ts b/packages/polar/src/shared/constants.ts index 1e98c9f..6398e9f 100644 --- a/packages/polar/src/shared/constants.ts +++ b/packages/polar/src/shared/constants.ts @@ -1,4 +1,8 @@ -import type { ProviderCapabilities } from 'paymesh'; +import type { + PaymentStatus, + PaymeshEventType, + ProviderCapabilities, +} from 'paymesh'; export const POLAR_BASE_URL = 'https://api.polar.sh'; @@ -12,3 +16,47 @@ export const POLAR_CAPABILITIES = { customerPortal: true, customers: true, } satisfies ProviderCapabilities; + +export const POLAR_CHECKOUT_STATUSES = { + succeeded: 'paid', + expired: 'canceled', + failed: 'failed', + open: 'pending', + confirmed: 'pending', +} satisfies Record; + +export const POLAR_ORDER_STATUSES = { + refunded: 'refunded', + partially_refunded: 'refunded', + void: 'canceled', + paid: 'paid', + pending: 'pending', +} satisfies Record; + +export const POLAR_WEBHOOK_EVENTS = { + 'checkout.created': 'payment.created', + 'order.created': 'payment.created', + 'order.paid': 'payment.succeeded', + 'order.refunded': 'payment.refunded', + 'customer.created': 'customer.created', + 'customer.updated': 'customer.updated', + 'customer.state_changed': 'customer.updated', + 'customer.deleted': 'customer.deleted', + 'subscription.created': 'subscription.created', + 'subscription.canceled': 'subscription.canceled', + 'subscription.revoked': 'subscription.canceled', +} satisfies Record; + +export const POLAR_WEBHOOK_HOOKS = { + 'payment.created': 'onPaymentCreated', + 'payment.succeeded': 'onPaymentSucceeded', + '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', +} satisfies Partial>; diff --git a/packages/polar/src/shared/mapper.ts b/packages/polar/src/shared/mapper.ts index 2e19055..7f7afdd 100644 --- a/packages/polar/src/shared/mapper.ts +++ b/packages/polar/src/shared/mapper.ts @@ -1,5 +1,11 @@ -import type { PaymentStatus } from 'paymesh'; -import type { PolarCheckout, PolarCustomer, PolarOrder } from '../types'; +import type { BaseCoupon, PaymentStatus } from 'paymesh'; +import type { + PolarCheckout, + PolarCustomer, + PolarDiscount, + PolarOrder, +} from '../types'; +import { POLAR_CHECKOUT_STATUSES, POLAR_ORDER_STATUSES } from './constants'; /** * Maps a Polar customer payload into a normalized Paymesh customer. @@ -23,10 +29,9 @@ export function mapPolarCheckoutPayment( checkout: PolarCheckout, sandbox: boolean, ) { - let status: PaymentStatus = 'pending'; - if (checkout.status === 'succeeded') status = 'paid'; - if (checkout.status === 'expired') status = 'canceled'; - if (checkout.status === 'failed') status = 'failed'; + const status: PaymentStatus = + (checkout.status ? POLAR_CHECKOUT_STATUSES[checkout.status] : undefined) ?? + 'pending'; return { id: checkout.id, @@ -56,14 +61,10 @@ export function mapPolarCheckoutPayment( * Maps a Polar order into a normalized Paymesh payment. */ export function mapPolarOrderPayment(order: PolarOrder, sandbox: boolean) { - const status: PaymentStatus = - order.status === 'refunded' - ? 'refunded' - : order.status === 'void' - ? 'canceled' - : order.paid - ? 'paid' - : 'pending'; + const status: PaymentStatus = order.paid + ? 'paid' + : ((order.status ? POLAR_ORDER_STATUSES[order.status] : undefined) ?? + 'pending'); return { id: order.id, @@ -85,3 +86,77 @@ export function mapPolarOrderPayment(order: PolarOrder, sandbox: boolean) { metadata: order.metadata ?? undefined, }; } + +export function mapPolarDiscount( + discount: PolarDiscount, + sandbox: boolean, + statusOverride?: BaseCoupon['status'], +): BaseCoupon { + const statusMap = { + expired: + typeof discount.ends_at === 'string' && + new Date(discount.ends_at).getTime() <= Date.now(), + inactive: discount.is_archived === true || discount.active === false, + scheduled: + typeof discount.starts_at === 'string' && + new Date(discount.starts_at).getTime() > Date.now(), + } as const; + + return { + id: discount.id, + provider: 'polar', + sandbox, + code: discount.code ?? discount.id, + name: discount.name ?? undefined, + status: statusOverride + ? statusOverride + : statusMap.expired + ? 'expired' + : statusMap.inactive + ? 'inactive' + : statusMap.scheduled + ? 'scheduled' + : 'active', + active: typeof discount.active === 'boolean' ? discount.active : undefined, + discount: + typeof discount.percentage === 'number' + ? { + type: 'percentage', + value: discount.percentage, + } + : { + type: 'fixed', + value: + typeof discount.amount === 'number' + ? discount.amount + : typeof discount.fixed_amount === 'number' + ? discount.fixed_amount + : 0, + currency: discount.currency?.toUpperCase() ?? undefined, + }, + duration: discount.duration + ? { + type: discount.duration, + durationInMonths: discount.duration_in_months ?? undefined, + } + : undefined, + startsAt: discount.starts_at + ? new Date(discount.starts_at).toISOString() + : undefined, + expiresAt: discount.ends_at + ? new Date(discount.ends_at).toISOString() + : undefined, + appliesTo: Array.isArray(discount.products) + ? { + products: discount.products.filter( + (value): value is string => typeof value === 'string', + ), + } + : undefined, + redemptions: { + count: discount.redemption_count ?? discount.times_redeemed ?? 0, + max: discount.max_redemptions ?? null, + }, + metadata: discount.metadata ?? undefined, + }; +} diff --git a/packages/polar/src/shared/sync.ts b/packages/polar/src/shared/sync.ts index 4474bcb..c6f7d40 100644 --- a/packages/polar/src/shared/sync.ts +++ b/packages/polar/src/shared/sync.ts @@ -100,5 +100,5 @@ export const syncPolarSubscription = async ({ ); await database.repositories.subscriptions.upsert(schema, event); - return event.data as unknown as Record; + return event.data; }; diff --git a/packages/polar/src/shared/webhooks.ts b/packages/polar/src/shared/webhooks.ts index 592314e..b448847 100644 --- a/packages/polar/src/shared/webhooks.ts +++ b/packages/polar/src/shared/webhooks.ts @@ -1,4 +1,10 @@ -import type { PaymeshEventType } from 'paymesh'; +import { createHmac, timingSafeEqual } from 'node:crypto'; +import type { + PaymeshEvent, + PaymeshEventType, + ProviderWebhookHandleOptions, + ProviderWebhookHandleResult, +} from 'paymesh'; import { withRaw } from 'paymesh'; import type { PolarCheckout, @@ -7,171 +13,168 @@ import type { PolarSubscription, PolarWebhookEvent, } from '../types'; +import { POLAR_WEBHOOK_EVENTS, POLAR_WEBHOOK_HOOKS } from './constants'; import { mapPolarCheckoutPayment, mapPolarCustomer, mapPolarOrderPayment, } from './mapper'; -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -/** - * Resolves the normalized Paymesh event type for a Polar webhook event. - */ -export function resolvePolarWebhookType( - event: PolarWebhookEvent, -): PaymeshEventType { - switch (event.type) { - case 'checkout.created': - return 'payment.created'; - case 'checkout.updated': { - const checkout = event.data as PolarCheckout; - return checkout.status === 'succeeded' - ? 'checkout.completed' - : checkout.status === 'expired' - ? 'payment.canceled' - : 'payment.created'; +function resolvePolarWebhookType(event: PolarWebhookEvent): PaymeshEventType { + if (event.type === 'checkout.updated') { + if ((event.data as PolarCheckout).status === 'succeeded') { + return 'checkout.completed'; } - case 'order.created': - return 'payment.created'; - case 'order.paid': - return 'payment.succeeded'; - case 'order.refunded': - return 'payment.refunded'; - case 'customer.created': - return 'customer.created'; - case 'customer.updated': - case 'customer.state_changed': - return 'customer.updated'; - case 'customer.deleted': - return 'customer.deleted'; - case 'subscription.created': - return 'subscription.created'; - case 'subscription.canceled': - case 'subscription.revoked': - return 'subscription.canceled'; - case 'subscription.updated': { - const subscription = event.data as PolarSubscription; - return subscription.canceled_at || subscription.ended_at - ? 'subscription.canceled' - : 'subscription.updated'; + + if ((event.data as PolarCheckout).status === 'expired') { + return 'payment.canceled'; } - default: - return 'payment.created'; + + return 'payment.created'; } + + if (event.type === 'subscription.updated') { + const subscription = event.data as PolarSubscription; + return subscription.canceled_at || subscription.ended_at + ? 'subscription.canceled' + : 'subscription.updated'; + } + + return ( + (POLAR_WEBHOOK_EVENTS as Partial>)[ + event.type + ] ?? 'payment.created' + ); } -/** - * Resolves the normalized webhook payload for Polar events. - */ -export function resolvePolarWebhookData( - type: PaymeshEventType, - event: PolarWebhookEvent, - includeRaw: boolean, - sandbox: boolean, +export async function verifyPolarWebhook( + request: Request, + webhookSecret?: string, ) { - let data: unknown = event.data; + if (!webhookSecret) return false; - if ( - type === 'checkout.completed' || - type === 'payment.created' || - type === 'payment.succeeded' || - type === 'payment.refunded' || - type === 'payment.canceled' - ) { - if (isRecord(event.data) && 'status' in event.data) { - const checkout = event.data as unknown as PolarCheckout; - data = withRaw( - mapPolarCheckoutPayment(checkout, sandbox), - checkout, - includeRaw, - ); - } else if (isRecord(event.data)) { - const order = event.data as unknown as PolarOrder; - data = withRaw(mapPolarOrderPayment(order, sandbox), order, includeRaw); - } - } else if (type === 'customer.created' || type === 'customer.updated') { - const customer = event.data as PolarCustomer; - data = withRaw(mapPolarCustomer(customer, sandbox), customer, includeRaw); - } else if (type === 'customer.deleted') { - const customer = event.data as PolarCustomer; - data = withRaw( - { - id: customer.id, - provider: 'polar', - sandbox, - deleted: true, - }, - customer, - includeRaw, - ); - } else if ( - type === 'subscription.created' || - type === 'subscription.updated' || - type === 'subscription.canceled' - ) { - const subscription = event.data as PolarSubscription; - data = withRaw( - { - ...subscription, - provider: 'polar', - sandbox, - }, - subscription, - includeRaw, - ); - } + const webhookId = request.headers.get('webhook-id'); + const timestamp = request.headers.get('webhook-timestamp'); + const signatureHeader = request.headers.get('webhook-signature'); + + if (!webhookId || !timestamp || !signatureHeader) return false; - return data; + const secret = webhookSecret.startsWith('whsec_') + ? Buffer.from(webhookSecret.slice('whsec_'.length), 'base64') + : Buffer.from(webhookSecret); + const actual = createHmac('sha256', secret) + .update(`${webhookId}.${timestamp}.${await request.text()}`) + .digest('base64'); + + return signatureHeader.split(/\s+/).some((part) => { + const [version, expected] = part.trim().split(',', 2); + return ( + version === 'v1' && + !!expected && + actual.length === expected.length && + timingSafeEqual(Buffer.from(actual), Buffer.from(expected)) + ); + }); } -/** - * Resolves the webhook delivery id from event data when available. - */ -export function resolvePolarWebhookId(event: PolarWebhookEvent) { - let id = `${event.type}:${event.timestamp}`; - if ( - event.data && - typeof event.data === 'object' && - 'id' in event.data && - typeof event.data.id === 'string' - ) { - id = event.data.id; +export async function handlePolarWebhook( + options: ProviderWebhookHandleOptions, + resolveProviderSandbox: () => boolean, +): Promise> { + const payload = await options.request.json(); + const webhookId = options.request.headers.get('webhook-id') ?? undefined; + + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new TypeError('Polar webhook payload must be a JSON object.'); } - return id; -} + const event = payload as PolarWebhookEvent; + const type = resolvePolarWebhookType(event); + const sandbox = resolveProviderSandbox(); + let data: unknown = event.data; -/** - * Resolves the webhook hook name for a normalized Paymesh event type. - */ -export function resolvePolarWebhookHook(type: PaymeshEventType) { switch (type) { case 'payment.created': - return 'onPaymentCreated'; case 'payment.succeeded': - return 'onPaymentSucceeded'; - case 'payment.canceled': - return 'onPaymentCanceled'; case 'payment.refunded': - return 'onPaymentRefunded'; + case 'payment.canceled': + case 'checkout.completed': + if ( + typeof event.data === 'object' && + event.data !== null && + 'status' in event.data + ) { + data = withRaw( + mapPolarCheckoutPayment(event.data as PolarCheckout, sandbox), + event.data, + options.includeRaw, + ); + } else if (typeof event.data === 'object' && event.data !== null) { + data = withRaw( + mapPolarOrderPayment(event.data as PolarOrder, sandbox), + event.data, + options.includeRaw, + ); + } + break; case 'customer.created': - return 'onCustomerCreated'; case 'customer.updated': - return 'onCustomerUpdated'; + data = withRaw( + mapPolarCustomer(event.data as PolarCustomer, sandbox), + event.data, + options.includeRaw, + ); + break; case 'customer.deleted': - return 'onCustomerDeleted'; + data = withRaw( + { + id: (event.data as PolarCustomer).id, + provider: 'polar', + sandbox, + deleted: true, + }, + event.data, + options.includeRaw, + ); + break; case 'subscription.created': - return 'onSubscriptionCreated'; case 'subscription.updated': - return 'onSubscriptionUpdated'; case 'subscription.canceled': - return 'onSubscriptionCanceled'; - case 'checkout.completed': - return 'onCheckoutCompleted'; - default: - return undefined; + data = withRaw( + { + ...(event.data as PolarSubscription), + provider: 'polar', + sandbox, + }, + event.data, + options.includeRaw, + ); + break; } + + const id = + typeof event.data === 'object' && + event.data !== null && + 'id' in event.data && + typeof event.data.id === 'string' + ? event.data.id + : `${event.type}:${event.timestamp}`; + + return { + deliveryId: webhookId, + hook: (POLAR_WEBHOOK_HOOKS as Partial>)[ + type + ], + event: withRaw( + { + id, + type, + provider: 'polar', + sandbox, + data, + }, + payload, + options.includeRaw, + ) as PaymeshEvent, + }; } diff --git a/packages/polar/src/types.ts b/packages/polar/src/types.ts index 83c2272..584f96e 100644 --- a/packages/polar/src/types.ts +++ b/packages/polar/src/types.ts @@ -6,6 +6,8 @@ import type { RetryOptions } from 'paymesh'; export interface PolarProviderOptions { /** API access token used for authenticated requests. Defaults to `process.env.POLAR_ACCESS_TOKEN`. */ accessToken?: string; + /** Optional Polar organization id used by discount APIs when required by the account. */ + organizationId?: string; /** Explicit sandbox override. When omitted, Polar infers from the configured base URL. */ sandbox?: boolean; /** Signing secret used to verify webhooks. Defaults to `process.env.POLAR_WEBHOOK_SECRET`. */ @@ -93,6 +95,27 @@ export interface PolarWebhookEvent { data: TData; } +export interface PolarDiscount { + id: string; + active?: boolean; + amount?: number | null; + code?: string | null; + currency?: string | null; + duration?: 'once' | 'forever' | 'repeating' | null; + duration_in_months?: number | null; + ends_at?: string | null; + fixed_amount?: number | null; + is_archived?: boolean; + max_redemptions?: number | null; + metadata?: Record | null; + name?: string | null; + percentage?: number | null; + products?: string[] | null; + redemption_count?: number | null; + starts_at?: string | null; + times_redeemed?: number | null; +} + /** * Polar product price payload subset used by Paymesh. */ @@ -129,3 +152,10 @@ export type PolarProductListResponse = data?: PolarProduct[]; result?: PolarProduct[]; }; + +export type PolarDiscountListResponse = + | PolarDiscount[] + | { + items?: PolarDiscount[]; + data?: PolarDiscount[]; + }; diff --git a/packages/polar/test/polar.test.ts b/packages/polar/test/polar.test.ts index d3f172c..3c90747 100644 --- a/packages/polar/test/polar.test.ts +++ b/packages/polar/test/polar.test.ts @@ -81,6 +81,196 @@ describe('polar provider', () => { expect(payment.raw).toBeNull(); }); + test('applies couponCode and allowCouponCodes in Polar checkouts', async () => { + const provider = polar({ + accessToken: 'polar_oat_123', + baseUrl: 'https://polar.test', + fetch: (async (input, init) => { + if (String(input).includes('/v1/discounts?')) { + return Response.json([ + { + id: 'disc_123', + code: 'PRO20', + percentage: 20, + }, + ]); + } + + expect(String(input)).toBe('https://polar.test/v1/checkouts'); + expect(JSON.parse(String(init?.body))).toEqual({ + products: ['prod_123'], + amount: 1000, + currency: 'usd', + customer_id: undefined, + external_customer_id: undefined, + customer_name: undefined, + customer_email: undefined, + metadata: undefined, + success_url: undefined, + return_url: undefined, + allow_discount_codes: true, + discount_id: 'disc_123', + }); + + return Response.json({ + id: 'chk_coupon', + status: 'open', + total_amount: 1000, + currency: 'usd', + }); + }) as typeof fetch, + }); + + await provider.payments.create({ + amount: 1000, + currency: 'USD', + productIds: ['prod_123'], + couponCode: 'PRO20', + allowCouponCodes: true, + }); + }); + + test('creates and checks Polar coupons', async () => { + const provider = polar({ + accessToken: 'polar_oat_123', + baseUrl: 'https://polar.coupons.test', + fetch: (async (input, init) => { + if (String(input) === 'https://polar.coupons.test/v1/discounts') { + expect(JSON.parse(String(init?.body))).toEqual({ + organization_id: undefined, + code: 'PRO20', + name: 'Pro 20%', + percentage: 20, + fixed_amount: undefined, + currency: undefined, + duration: 'once', + duration_in_months: undefined, + starts_at: undefined, + ends_at: undefined, + max_redemptions: undefined, + products: ['prod_pro'], + metadata: undefined, + }); + return Response.json({ + id: 'disc_123', + code: 'PRO20', + name: 'Pro 20%', + percentage: 20, + duration: 'once', + products: ['prod_pro'], + redemption_count: 0, + max_redemptions: null, + }); + } + if (String(input).includes('/v1/discounts?')) { + return Response.json([ + { + id: 'disc_123', + code: 'PRO20', + name: 'Pro 20%', + percentage: 20, + duration: 'once', + products: ['prod_pro'], + redemption_count: 0, + max_redemptions: null, + }, + ]); + } + + throw new Error(`Unexpected Polar request: ${String(input)}`); + }) as typeof fetch, + }); + + const coupon = await provider.coupons?.create({ + code: 'PRO20', + name: 'Pro 20%', + discount: { type: 'percentage', value: 20 }, + duration: { type: 'once' }, + appliesTo: { products: ['prod_pro'] }, + }); + const check = await provider.coupons?.check({ + code: 'PRO20', + amount: 1000, + currency: 'USD', + productIds: ['prod_pro'], + }); + + expect(coupon).toMatchObject({ + id: 'disc_123', + code: 'PRO20', + status: 'active', + }); + expect(check).toMatchObject({ + valid: true, + preview: { + subtotal: 1000, + discountTotal: 200, + total: 800, + currency: 'USD', + }, + }); + }); + + test('archives and deletes Polar coupons with distinct semantics', async () => { + const calls: string[] = []; + const provider = polar({ + accessToken: 'polar_oat_123', + baseUrl: 'https://polar.coupons.test', + fetch: (async (input, init) => { + const url = String(input); + calls.push(`${init?.method ?? 'GET'} ${url}`); + + if ( + url === 'https://polar.coupons.test/v1/discounts/disc_123' && + init?.method === 'PATCH' + ) { + expect(JSON.parse(String(init?.body))).toEqual({ + active: false, + }); + return Response.json({ + id: 'disc_123', + code: 'PRO20', + percentage: 20, + active: false, + redemption_count: 0, + max_redemptions: null, + }); + } + + if ( + url === 'https://polar.coupons.test/v1/discounts/disc_123' && + init?.method === 'DELETE' + ) { + return Response.json({ + id: 'disc_123', + code: 'PRO20', + }); + } + + throw new Error(`Unexpected Polar request: ${url}`); + }) as typeof fetch, + }); + + const archived = await provider.coupons?.archive?.('disc_123'); + const deleted = await provider.coupons?.delete('disc_123'); + + expect(archived).toMatchObject({ + id: 'disc_123', + code: 'PRO20', + status: 'archived', + active: false, + }); + expect(deleted).toMatchObject({ + id: 'disc_123', + deleted: true, + code: 'PRO20', + }); + expect(calls).toEqual([ + 'PATCH https://polar.coupons.test/v1/discounts/disc_123', + 'DELETE https://polar.coupons.test/v1/discounts/disc_123', + ]); + }); + test('requires productIds to create a checkout session', async () => { const provider = polar({ accessToken: 'polar_oat_123', diff --git a/packages/stripe/src/index.ts b/packages/stripe/src/index.ts index d9d0c60..7b22d3a 100644 --- a/packages/stripe/src/index.ts +++ b/packages/stripe/src/index.ts @@ -1,29 +1,34 @@ -import { createHmac, timingSafeEqual } from 'node:crypto'; import { + type BaseCouponDeleteResult, + type CouponCheckData, + type CouponCheckResult, + type CouponCreateData, + type CouponListOptions, + type CouponListResult, + type CouponUpdateData, type CustomerUpsertData, defineProvider, type PaymentCreateData, - type PaymentStatus, PaymeshError, - type PaymeshEvent, type PixCreateData, type ProviderRequestOptions, - type ProviderWebhookHandleOptions, - type ProviderWebhookHandleResult, request, withRaw, } from 'paymesh'; +import { STRIPE_BASE_URL, STRIPE_CAPABILITIES } from './shared/constants'; import { - STRIPE_BASE_URL, - STRIPE_CAPABILITIES, - STRIPE_EVENTS, - STRIPE_HOOKS, - STRIPE_PAYMENT_STATUSES, -} from './shared/constants'; + assertStripeCouponFields, + findStripePromotionCodeByCode, + getStripePromotionCode, + resolveStripeCartPreview, + resolveStripePromotionCoupon, +} from './shared/coupons'; import { mapStripeCustomer, + mapStripeDeletedCustomer, mapStripePaymentObject, mapStripePixIntent, + mapStripePromotionCode, } from './shared/mapper'; import { syncStripePayment, @@ -31,16 +36,18 @@ import { syncStripeSubscription, } from './shared/sync'; import { isStripePixPaymentIntent } from './shared/utils'; +import { handleStripeWebhook, verifyStripeWebhook } from './shared/webhooks'; import type { StripeBalance, StripeCheckoutSession, + StripeCoupon, StripeCustomer, StripeDeletedCustomer, - StripeEvent, StripeListResponse, StripePaymentObject, StripePrice, StripeProduct, + StripePromotionCode, StripeProviderOptions, } from './types'; @@ -123,6 +130,7 @@ export const stripe = ({ if (data.successUrl) body.set('success_url', data.successUrl); if (data.cancelUrl) body.set('cancel_url', data.cancelUrl); + if (data.allowCouponCodes) body.set('allow_promotion_codes', 'true'); if (data.customer?.id) { body.set('customer', data.customer.id); @@ -139,6 +147,25 @@ export const stripe = ({ if (value !== null) body.set(`metadata[${key}]`, String(value)); } + if (data.couponCode) { + const resolvedPromotion = await findStripePromotionCodeByCode( + data.couponCode, + resolveRequestOptions, + options, + ); + if (!resolvedPromotion) { + throw new PaymeshError({ + code: 'provider_not_found', + message: `Stripe coupon code "${data.couponCode}" was not found.`, + provider: 'stripe', + }); + } + body.set( + 'discounts[0][promotion_code]', + String(resolvedPromotion.promotionCode.id), + ); + } + const session = await request( '/v1/checkout/sessions', { @@ -148,41 +175,465 @@ export const stripe = ({ body, }, ); - const status: PaymentStatus = - STRIPE_PAYMENT_STATUSES[ - session.payment_status ?? session.status ?? '' - ] ?? 'pending'; return withRaw( + mapStripePaymentObject(session, resolveProviderSandbox()), + session, + options?.includeRaw, + ); + }, + }, + coupons: { + async create( + data: CouponCreateData, + options?: ProviderRequestOptions, + ) { + assertStripeCouponFields('create', data); + if ( + data.discount.type === 'fixed' && + !data.discount.currency && + !data.discount.amounts + ) { + throw new PaymeshError({ + code: 'invalid_request', + message: + 'Stripe fixed-amount coupons require either discount.currency or discount.amounts.', + provider: 'stripe', + }); + } + + const couponBody = new URLSearchParams(); + if (data.name) couponBody.set('name', data.name); + if (data.duration?.type) couponBody.set('duration', data.duration.type); + if ( + data.duration?.type === 'repeating' && + typeof data.duration.durationInMonths === 'number' + ) { + couponBody.set( + 'duration_in_months', + String(data.duration.durationInMonths), + ); + } + if (data.discount.type === 'percentage') { + couponBody.set('percent_off', String(data.discount.value)); + } else if (data.discount.amounts) { + for (const [currency, amount] of Object.entries( + data.discount.amounts, + )) { + couponBody.set( + `currency_options[${currency.toLowerCase()}][amount_off]`, + String(amount), + ); + } + } else { + couponBody.set('amount_off', String(data.discount.value)); + couponBody.set('currency', data.discount.currency!.toLowerCase()); + } + for (const productId of data.appliesTo?.products ?? []) { + couponBody.append('applies_to[products][]', productId); + } + for (const [key, value] of Object.entries(data.metadata ?? {})) { + if (value !== null) couponBody.set(`metadata[${key}]`, String(value)); + } + + const createdCoupon = await request('/v1/coupons', { + provider: 'stripe', + ...resolveRequestOptions(options), + method: 'POST', + body: couponBody, + }); + + const promotionBody = new URLSearchParams({ + code: data.code, + coupon: String(createdCoupon.id), + }); + if (data.expiresAt) { + const expiresAt = + data.expiresAt instanceof Date + ? data.expiresAt + : new Date(data.expiresAt); + + promotionBody.set( + 'expires_at', + String(Math.floor(expiresAt.getTime() / 1000)), + ); + } + if (typeof data.maxRedemptions === 'number') { + promotionBody.set('max_redemptions', String(data.maxRedemptions)); + } + if (data.customerId) promotionBody.set('customer', data.customerId); + if (data.firstTimeOnly) { + promotionBody.set('restrictions[first_time_transaction]', 'true'); + } + if (typeof data.minimumAmount === 'number') { + if (!data.minimumAmountCurrency) { + throw new PaymeshError({ + code: 'invalid_request', + message: 'Stripe minimumAmount requires minimumAmountCurrency.', + provider: 'stripe', + }); + } + promotionBody.set( + 'restrictions[minimum_amount]', + String(data.minimumAmount), + ); + promotionBody.set( + 'restrictions[minimum_amount_currency]', + data.minimumAmountCurrency.toLowerCase(), + ); + } + for (const [key, value] of Object.entries(data.metadata ?? {})) { + if (value !== null) + promotionBody.set(`metadata[${key}]`, String(value)); + } + + const promotionCode = await request( + '/v1/promotion_codes', + { + provider: 'stripe', + ...resolveRequestOptions(options), + method: 'POST', + body: promotionBody, + }, + ); + const coupon = mapStripePromotionCode( + promotionCode, + createdCoupon, + resolveProviderSandbox(), + ); + return withRaw( + coupon, + { coupon: createdCoupon, promotionCode }, + options?.includeRaw, + ); + }, + async get( + id: string, + options?: ProviderRequestOptions, + ) { + const promotionCode = await getStripePromotionCode( + id, + resolveRequestOptions, + options, + ); + const coupon = await resolveStripePromotionCoupon( + promotionCode, + resolveRequestOptions, + options, + ); + return withRaw( + mapStripePromotionCode( + promotionCode, + coupon, + resolveProviderSandbox(), + ), + { coupon, promotionCode }, + options?.includeRaw, + ); + }, + async list( + listOptions: CouponListOptions | undefined, + options?: ProviderRequestOptions, + ) { + const query = new URLSearchParams(); + if (typeof listOptions?.limit === 'number') { + query.set('limit', String(listOptions.limit)); + } + if (listOptions?.after) query.set('starting_after', listOptions.after); + if (listOptions?.before) query.set('ending_before', listOptions.before); + if (listOptions?.code) query.set('code', listOptions.code); + if (typeof listOptions?.active === 'boolean') { + query.set('active', String(listOptions.active)); + } + query.append('expand[]', 'data.coupon'); + const response = await request>( + `/v1/promotion_codes${query.size > 0 ? `?${query.toString()}` : ''}`, { - id: session.id, provider: 'stripe', - sandbox: resolveProviderSandbox(), - amount: session.amount_total ?? 0, - currency: session.currency ?? 'usd', - status, - checkoutUrl: session.url ?? undefined, - customer: session.customer_details - ? { - id: - typeof session.customer === 'string' - ? session.customer - : undefined, - externalId: session.client_reference_id ?? undefined, - name: session.customer_details.name ?? undefined, - email: - session.customer_details.email ?? - session.customer_email ?? - undefined, - phone: session.customer_details.phone ?? undefined, - } - : undefined, - metadata: session.metadata ?? undefined, + ...resolveRequestOptions(options), + method: 'GET', }, - session, + ); + const data = await Promise.all( + response.data.map(async (promotionCode) => { + const coupon = await resolveStripePromotionCoupon( + promotionCode, + resolveRequestOptions, + options, + ); + return withRaw( + mapStripePromotionCode( + promotionCode, + coupon, + resolveProviderSandbox(), + ), + { coupon, promotionCode }, + options?.includeRaw, + ); + }), + ); + return { + data, + total: data.length, + previous: null, + next: + response.has_more && data.length > 0 + ? data[data.length - 1]!.id + : null, + } satisfies CouponListResult; + }, + async update( + id: string, + data: CouponUpdateData, + options?: ProviderRequestOptions, + ) { + assertStripeCouponFields('update', data); + const body = new URLSearchParams(); + if (typeof data.active === 'boolean') { + body.set('active', String(data.active)); + } + for (const [key, value] of Object.entries(data.metadata ?? {})) { + if (value !== null) body.set(`metadata[${key}]`, String(value)); + } + const promotionCode = await request( + `/v1/promotion_codes/${encodeURIComponent(id)}`, + { + provider: 'stripe', + ...resolveRequestOptions(options), + method: 'POST', + body, + }, + ); + const coupon = await resolveStripePromotionCoupon( + promotionCode, + resolveRequestOptions, + options, + ); + return withRaw( + mapStripePromotionCode( + promotionCode, + coupon, + resolveProviderSandbox(), + ), + { coupon, promotionCode }, + options?.includeRaw, + ); + }, + async archive( + id: string, + options?: ProviderRequestOptions, + ) { + const body = new URLSearchParams({ active: 'false' }); + const promotionCode = await request( + `/v1/promotion_codes/${encodeURIComponent(id)}`, + { + provider: 'stripe', + ...resolveRequestOptions(options), + method: 'POST', + body, + }, + ); + const coupon = await resolveStripePromotionCoupon( + promotionCode, + resolveRequestOptions, + options, + ); + return withRaw( + mapStripePromotionCode( + promotionCode, + coupon, + resolveProviderSandbox(), + 'archived', + ), + { coupon, promotionCode }, options?.includeRaw, ); }, + async delete( + id: string, + options?: ProviderRequestOptions, + ) { + const promotionCode = await getStripePromotionCode( + id, + resolveRequestOptions, + options, + ); + const coupon = await resolveStripePromotionCoupon( + promotionCode, + resolveRequestOptions, + options, + ); + if (promotionCode.active !== false) { + await request( + `/v1/promotion_codes/${encodeURIComponent(id)}`, + { + provider: 'stripe', + ...resolveRequestOptions(options), + method: 'POST', + body: new URLSearchParams({ active: 'false' }), + }, + ); + } + await request( + `/v1/coupons/${encodeURIComponent(String(coupon.id))}`, + { + provider: 'stripe', + ...resolveRequestOptions(options), + method: 'DELETE', + }, + ); + const result: BaseCouponDeleteResult = { + id, + provider: 'stripe', + sandbox: resolveProviderSandbox(), + deleted: true, + code: + typeof promotionCode.code === 'string' + ? promotionCode.code + : undefined, + }; + return withRaw(result, promotionCode, options?.includeRaw); + }, + async check( + data: CouponCheckData, + options?: ProviderRequestOptions, + ): Promise> { + try { + const resolved = await findStripePromotionCodeByCode( + data.code, + resolveRequestOptions, + options, + ); + if (!resolved) { + return { valid: false, reason: 'not_found' }; + } + const coupon = mapStripePromotionCode( + resolved.promotionCode, + resolved.coupon, + resolveProviderSandbox(), + ); + const normalizedCoupon = withRaw( + coupon, + { coupon: resolved.coupon, promotionCode: resolved.promotionCode }, + options?.includeRaw, + ); + if (coupon.status === 'expired') { + return { + valid: false, + reason: 'expired', + coupon: normalizedCoupon, + }; + } + if (coupon.active === false || coupon.status === 'inactive') { + return { + valid: false, + reason: 'inactive', + coupon: normalizedCoupon, + }; + } + if ( + coupon.customerId && + data.customerId && + coupon.customerId !== data.customerId + ) { + return { + valid: false, + reason: 'customer_not_eligible', + coupon: normalizedCoupon, + }; + } + if ( + typeof coupon.redemptions.max === 'number' && + coupon.redemptions.count >= coupon.redemptions.max + ) { + return { + valid: false, + reason: 'max_redemptions_reached', + coupon: normalizedCoupon, + }; + } + const previewInput = await resolveStripeCartPreview( + data, + resolveRequestOptions, + options, + ); + if (!previewInput) { + return { valid: true, coupon: normalizedCoupon }; + } + if ( + coupon.appliesTo?.products?.length && + previewInput.productIds.length > 0 && + !previewInput.productIds.some((productId) => + coupon.appliesTo?.products?.includes(productId), + ) + ) { + return { + valid: false, + reason: 'product_not_eligible', + coupon: normalizedCoupon, + }; + } + if ( + typeof coupon.minimumAmount === 'number' && + previewInput.subtotal < coupon.minimumAmount + ) { + return { + valid: false, + reason: 'minimum_amount_not_reached', + coupon: normalizedCoupon, + }; + } + if (coupon.firstTimeOnly) { + return { + valid: false, + reason: 'provider_error', + message: + 'Stripe first-time-only promotion codes cannot be verified locally.', + coupon: normalizedCoupon, + }; + } + let discountTotal = 0; + if (coupon.discount.type === 'percentage') { + discountTotal = Math.floor( + (previewInput.subtotal * coupon.discount.value) / 100, + ); + } else { + const fixedAmount = + coupon.discount.amounts?.[previewInput.currency] ?? + (coupon.discount.currency === previewInput.currency + ? coupon.discount.value + : undefined); + if (typeof fixedAmount !== 'number') { + return { + valid: false, + reason: 'currency_not_supported', + coupon: normalizedCoupon, + }; + } + discountTotal = fixedAmount; + } + discountTotal = Math.min(previewInput.subtotal, discountTotal); + return { + valid: true, + coupon: normalizedCoupon, + preview: { + subtotal: previewInput.subtotal, + discountTotal, + total: previewInput.subtotal - discountTotal, + currency: previewInput.currency, + }, + }; + } catch (error) { + return { + valid: false, + reason: 'provider_error', + message: + error instanceof Error + ? error.message + : 'Stripe coupon check failed.', + }; + } + }, }, pix: { async create( @@ -355,12 +806,7 @@ export const stripe = ({ ); return withRaw( - { - id: customer.id, - provider: 'stripe', - sandbox: resolveProviderSandbox(), - deleted: customer.deleted, - }, + mapStripeDeletedCustomer(customer, resolveProviderSandbox()), customer, options?.includeRaw, ); @@ -475,96 +921,15 @@ export const stripe = ({ }, webhooks: { async verify({ request }) { - if (!webhookSecret) return false; - - const signature = new URLSearchParams( - request.headers.get('stripe-signature')?.replaceAll(',', '&'), - ); - const timestamp = signature.get('t'); - const expected = signature.get('v1'); - if (!timestamp || !expected) return false; - - const payload = `${timestamp}.${await request.text()}`; - const actual = createHmac('sha256', webhookSecret) - .update(payload) - .digest('hex'); - - return ( - actual.length === expected.length && - timingSafeEqual(Buffer.from(actual), Buffer.from(expected)) - ); + return verifyStripeWebhook(request, webhookSecret); }, - async handle( - options: ProviderWebhookHandleOptions, - ): Promise> { - const { request, includeRaw } = options; - const payload = await request.json(); - - if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { - throw new TypeError('Stripe webhook payload must be a JSON object.'); - } - - const body = payload as Record; - const event = body as unknown as StripeEvent; - const object = event.data?.object; - const type = STRIPE_EVENTS[event.type] ?? 'payment.created'; - const eventSandbox = - typeof event.livemode === 'boolean' - ? !event.livemode - : resolveProviderSandbox(); - - let data: unknown = body; - - if (object) { - if (type === 'customer.deleted') { - const customer = object as StripeDeletedCustomer; - - data = withRaw( - { - id: customer.id, - provider: 'stripe', - sandbox: eventSandbox, - deleted: customer.deleted, - }, - customer, - includeRaw, - ); - } else if ( - type === 'customer.created' || - type === 'customer.updated' - ) { - const customer = object as StripeCustomer; - - data = withRaw( - mapStripeCustomer(customer, eventSandbox), - customer, - includeRaw, - ); - } else { - const payment = object as StripePaymentObject; - data = withRaw( - mapStripePaymentObject(payment, eventSandbox), - payment, - includeRaw, - ); - } - } - - return { - deliveryId: event.id, - hook: STRIPE_HOOKS[type], - event: withRaw( - { - id: event.id, - type, - provider: 'stripe', - sandbox: eventSandbox, - data, - }, - body, - includeRaw, - ) as PaymeshEvent, - }; + async handle(options) { + return handleStripeWebhook( + options, + resolveProviderSandbox, + (promotionCode) => + resolveStripePromotionCoupon(promotionCode, resolveRequestOptions), + ); }, }, }); diff --git a/packages/stripe/src/shared/constants.ts b/packages/stripe/src/shared/constants.ts index 1dd21a6..b4f8042 100644 --- a/packages/stripe/src/shared/constants.ts +++ b/packages/stripe/src/shared/constants.ts @@ -23,23 +23,24 @@ export const STRIPE_EVENTS: Record = { 'payment_intent.payment_failed': 'payment.failed', 'payment_intent.canceled': 'payment.canceled', 'charge.refunded': 'payment.refunded', + 'promotion_code.created': 'coupon.created', + 'promotion_code.updated': 'coupon.updated', 'customer.created': 'customer.created', 'customer.updated': 'customer.updated', 'customer.deleted': 'customer.deleted', }; -export const STRIPE_HOOKS: Record = { +export const STRIPE_HOOKS: Partial> = { 'payment.created': 'onPaymentCreated', 'payment.succeeded': 'onPaymentSucceeded', 'payment.failed': 'onPaymentFailed', 'payment.canceled': 'onPaymentCanceled', 'payment.refunded': 'onPaymentRefunded', + 'coupon.created': 'onCouponCreated', + 'coupon.updated': 'onCouponUpdated', 'customer.created': 'onCustomerCreated', 'customer.updated': 'onCustomerUpdated', 'customer.deleted': 'onCustomerDeleted', - 'subscription.created': 'onSubscriptionCreated', - 'subscription.updated': 'onSubscriptionUpdated', - 'subscription.canceled': 'onSubscriptionCanceled', 'checkout.completed': 'onCheckoutCompleted', }; diff --git a/packages/stripe/src/shared/coupons.ts b/packages/stripe/src/shared/coupons.ts new file mode 100644 index 0000000..ff6053e --- /dev/null +++ b/packages/stripe/src/shared/coupons.ts @@ -0,0 +1,284 @@ +import { + type CouponCheckData, + type CouponCreateData, + type CouponUpdateData, + PaymeshError, + type ProviderRequestOptions, + type RetryOptions, + request, +} from 'paymesh'; +import type { + StripeCoupon, + StripeListResponse, + StripePrice, + StripePromotionCode, +} from '../types'; + +type StripeRequestOptionsResolver = ( + options?: ProviderRequestOptions, +) => { + baseUrl: string; + fetch?: typeof fetch; + headers: Record; + retry?: RetryOptions; + timeout?: number; +}; + +export function assertStripeCouponFields( + mode: 'create' | 'update', + data: CouponCreateData | CouponUpdateData, +) { + const unsupported: string[] = []; + + if ('startsAt' in data && data.startsAt != null) unsupported.push('startsAt'); + if (data.appliesTo?.prices?.length) unsupported.push('appliesTo.prices'); + + if ( + mode === 'update' && + typeof data.code === 'string' && + data.code.length > 0 + ) { + unsupported.push('code'); + } + + if ( + mode === 'update' && + (data.discount !== undefined || + data.duration !== undefined || + data.customerId !== undefined || + data.firstTimeOnly !== undefined || + data.minimumAmount !== undefined || + data.minimumAmountCurrency !== undefined || + data.appliesTo !== undefined || + data.maxRedemptions !== undefined) + ) { + unsupported.push( + 'discount', + 'duration', + 'customerId', + 'firstTimeOnly', + 'minimumAmount', + 'minimumAmountCurrency', + 'appliesTo', + 'maxRedemptions', + ); + } + + if (unsupported.length === 0) return; + + throw new PaymeshError({ + code: 'invalid_request', + message: `Provider "stripe" does not support coupon field(s): ${[...new Set(unsupported)].join(', ')}`, + provider: 'stripe', + }); +} + +export async function getStripeCoupon( + id: string, + resolveRequestOptions: StripeRequestOptionsResolver, + options?: ProviderRequestOptions, +) { + return request(`/v1/coupons/${encodeURIComponent(id)}`, { + provider: 'stripe', + ...resolveRequestOptions(options), + method: 'GET', + }); +} + +export async function getStripePromotionCode< + IncludeRaw extends boolean = false, +>( + id: string, + resolveRequestOptions: StripeRequestOptionsResolver, + options?: ProviderRequestOptions, +) { + return request( + `/v1/promotion_codes/${encodeURIComponent(id)}?expand[]=coupon`, + { + provider: 'stripe', + ...resolveRequestOptions(options), + method: 'GET', + }, + ); +} + +export async function resolveStripePromotionCoupon< + IncludeRaw extends boolean = false, +>( + promotionCode: StripePromotionCode, + resolveRequestOptions: StripeRequestOptionsResolver, + options?: ProviderRequestOptions, +) { + if (typeof promotionCode.coupon === 'string') { + return getStripeCoupon( + promotionCode.coupon, + resolveRequestOptions, + options, + ); + } + + if (promotionCode.coupon) { + return promotionCode.coupon; + } + + throw new PaymeshError({ + code: 'provider_error', + message: + 'Stripe promotion code response did not include a coupon reference.', + provider: 'stripe', + }); +} + +export async function findStripePromotionCodeByCode< + IncludeRaw extends boolean = false, +>( + code: string, + resolveRequestOptions: StripeRequestOptionsResolver, + options?: ProviderRequestOptions, +) { + const response = await request>( + `/v1/promotion_codes?code=${encodeURIComponent(code)}&limit=1&expand[]=data.coupon`, + { + provider: 'stripe', + ...resolveRequestOptions(options), + method: 'GET', + }, + ); + + const promotionCode = response.data[0]; + if (!promotionCode) return null; + + return { + promotionCode, + coupon: await resolveStripePromotionCoupon( + promotionCode, + resolveRequestOptions, + options, + ), + }; +} + +export async function resolveStripeCartPreview< + IncludeRaw extends boolean = false, +>( + data: CouponCheckData, + resolveRequestOptions: StripeRequestOptionsResolver, + options?: ProviderRequestOptions, +) { + if (typeof data.amount === 'number' && typeof data.currency === 'string') { + return { + subtotal: data.amount, + currency: data.currency.toUpperCase(), + productIds: data.productIds ?? [], + }; + } + + if (data.priceIds?.length) { + const prices = await Promise.all( + data.priceIds.map((id) => + request(`/v1/prices/${encodeURIComponent(id)}`, { + provider: 'stripe', + ...resolveRequestOptions(options), + method: 'GET', + }), + ), + ); + + const currency = prices[0]?.currency; + if (!currency) { + throw new PaymeshError({ + code: 'provider_error', + message: 'Stripe price response did not include a currency.', + provider: 'stripe', + }); + } + + const subtotal = prices.reduce((total, price) => { + if ( + typeof price.unit_amount !== 'number' || + price.currency !== currency + ) { + throw new PaymeshError({ + code: 'provider_error', + message: + 'Stripe coupon preview requires prices with a single concrete currency and unit_amount.', + provider: 'stripe', + }); + } + + return total + price.unit_amount; + }, 0); + + return { + subtotal, + currency: currency.toUpperCase(), + productIds: prices + .map((price) => price.product ?? undefined) + .filter((value): value is string => Boolean(value)), + }; + } + + if (!data.productIds?.length) { + return null; + } + + let currency: string | undefined; + let subtotal = 0; + + for (const productId of data.productIds) { + const response = await request>( + `/v1/prices?product=${encodeURIComponent(productId)}&active=true&limit=100`, + { + provider: 'stripe', + ...resolveRequestOptions(options), + method: 'GET', + }, + ); + + const prices = response.data.filter( + (price) => typeof price.unit_amount === 'number', + ); + + if (prices.length !== 1) { + throw new PaymeshError({ + code: 'provider_error', + message: `Stripe coupon preview requires exactly one active price for product "${productId}".`, + provider: 'stripe', + }); + } + + const price = prices[0]!; + if (!price.currency) { + throw new PaymeshError({ + code: 'provider_error', + message: `Stripe price for product "${productId}" did not include a currency.`, + provider: 'stripe', + }); + } + + if (currency && currency !== price.currency) { + throw new PaymeshError({ + code: 'provider_error', + message: 'Stripe coupon preview does not support mixed-currency carts.', + provider: 'stripe', + }); + } + + currency = price.currency; + subtotal += price.unit_amount!; + } + + if (!currency) { + throw new PaymeshError({ + code: 'provider_error', + message: 'Stripe coupon preview could not resolve a cart currency.', + provider: 'stripe', + }); + } + + return { + subtotal, + currency: currency.toUpperCase(), + productIds: data.productIds, + }; +} diff --git a/packages/stripe/src/shared/mapper.ts b/packages/stripe/src/shared/mapper.ts index ef5e468..8020da4 100644 --- a/packages/stripe/src/shared/mapper.ts +++ b/packages/stripe/src/shared/mapper.ts @@ -1,12 +1,73 @@ -import type { BaseAnyPayment, BasePayment, PaymentStatus } from 'paymesh'; +import type { + BaseAnyPayment, + BaseCoupon, + BaseCustomerDeleteResult, + BasePayment, + PaymentStatus, +} from 'paymesh'; import type { StripeCheckoutSession, + StripeCoupon, StripeCustomer, + StripeDeletedCustomer, StripePaymentObject, + StripePromotionCode, } from '../types'; import { STRIPE_PAYMENT_STATUSES } from './constants'; import { getStripeExternalId, isStripePixPaymentIntent } from './utils'; +function mapStripeCheckoutCustomer(payment: StripeCheckoutSession) { + if (!payment.customer_details) return undefined; + + return { + id: typeof payment.customer === 'string' ? payment.customer : undefined, + externalId: payment.client_reference_id ?? undefined, + name: payment.customer_details.name ?? undefined, + email: + payment.customer_details.email ?? payment.customer_email ?? undefined, + phone: payment.customer_details.phone ?? undefined, + }; +} + +function mapStripeIntentCustomer( + payment: Extract, +) { + const externalId = getStripeExternalId(payment.metadata); + const customerId = + typeof payment.customer === 'string' ? payment.customer : undefined; + const email = + typeof payment.receipt_email === 'string' + ? payment.receipt_email + : undefined; + + if (!customerId && !email && !externalId) { + return undefined; + } + + return { + id: customerId, + email, + externalId, + }; +} + +function resolveStripePaymentStatus( + payment: StripePaymentObject, +): PaymentStatus { + if ('payment_status' in payment) { + return ( + STRIPE_PAYMENT_STATUSES[payment.payment_status ?? payment.status ?? ''] ?? + 'pending' + ); + } + + if ('refunded' in payment && payment.refunded) { + return 'refunded'; + } + + return STRIPE_PAYMENT_STATUSES[payment.status ?? ''] ?? 'pending'; +} + export function mapStripeCustomer(customer: StripeCustomer, sandbox: boolean) { return { id: customer.id, @@ -20,6 +81,18 @@ export function mapStripeCustomer(customer: StripeCustomer, sandbox: boolean) { }; } +export function mapStripeDeletedCustomer( + customer: StripeDeletedCustomer, + sandbox: boolean, +): BaseCustomerDeleteResult { + return { + id: customer.id, + provider: 'stripe', + sandbox, + deleted: customer.deleted, + }; +} + export function mapStripePaymentObject( payment: StripeCheckoutSession, sandbox: boolean, @@ -40,64 +113,31 @@ export function mapStripePaymentObject( return mapStripePixIntent(payment, sandbox); } - const status: PaymentStatus = - ('payment_status' in payment && - STRIPE_PAYMENT_STATUSES[ - payment.payment_status ?? payment.status ?? '' - ]) || - ('refunded' in payment && payment.refunded && 'refunded') || - STRIPE_PAYMENT_STATUSES[payment.status ?? ''] || - 'pending'; - - let customer: - | { - email?: string; - externalId?: string; - id?: string; - name?: string; - phone?: string; - } - | undefined; - - if ('customer_details' in payment) { - customer = { - id: typeof payment.customer === 'string' ? payment.customer : undefined, - externalId: - 'client_reference_id' in payment - ? (payment.client_reference_id ?? undefined) - : undefined, - name: payment.customer_details?.name ?? undefined, - email: - payment.customer_details?.email ?? payment.customer_email ?? undefined, - phone: payment.customer_details?.phone ?? undefined, - }; - } else if (payment.object === 'payment_intent') { - if ( - typeof payment.customer === 'string' || - typeof payment.receipt_email === 'string' || - getStripeExternalId(payment.metadata) - ) { - customer = { - id: typeof payment.customer === 'string' ? payment.customer : undefined, - email: payment.receipt_email ?? undefined, - externalId: getStripeExternalId(payment.metadata), - }; - } + let customer: BasePayment['customer']; + let amount: number; + let checkoutUrl: string | undefined; + + if (payment.object === 'checkout.session') { + amount = payment.amount_total ?? 0; + checkoutUrl = payment.url ?? undefined; + customer = mapStripeCheckoutCustomer(payment); + } else { + amount = payment.amount; + checkoutUrl = undefined; + } + + if (payment.object === 'payment_intent') { + customer = mapStripeIntentCustomer(payment); } return { id: payment.id, provider: 'stripe' as const, sandbox, - amount: - 'amount_total' in payment - ? (payment.amount_total ?? 0) - : 'amount' in payment - ? payment.amount - : 0, + amount, currency: payment.currency ?? 'usd', - status, - checkoutUrl: 'url' in payment ? (payment.url ?? undefined) : undefined, + status: resolveStripePaymentStatus(payment), + checkoutUrl, customer, metadata: payment.metadata ?? undefined, }; @@ -111,6 +151,17 @@ export function mapStripePixIntent( STRIPE_PAYMENT_STATUSES[payment.status ?? ''] ?? 'pending'; const qrCode = payment.next_action?.pix_display_qr_code; + const externalId = getStripeExternalId(payment.metadata); + const customerId = + typeof payment.customer === 'string' ? payment.customer : undefined; + const email = + typeof payment.receipt_email === 'string' + ? payment.receipt_email + : undefined; + const pixExpiration = + typeof qrCode?.expires_at === 'number' + ? qrCode.expires_at + : payment.payment_method_options?.pix?.expires_at; return { id: payment.id, @@ -120,26 +171,17 @@ export function mapStripePixIntent( copyPasteCode: qrCode?.data ?? undefined, currency: payment.currency ?? 'brl', customer: - typeof payment.customer === 'string' || - typeof payment.receipt_email === 'string' || - getStripeExternalId(payment.metadata) + customerId || email || externalId ? { - id: - typeof payment.customer === 'string' - ? payment.customer - : undefined, - email: payment.receipt_email ?? undefined, - externalId: getStripeExternalId(payment.metadata), + id: customerId, + email, + externalId, } : undefined, expiresAt: - typeof qrCode?.expires_at === 'number' - ? new Date(qrCode.expires_at * 1000).toISOString() - : typeof payment.payment_method_options?.pix?.expires_at === 'number' - ? new Date( - payment.payment_method_options.pix.expires_at * 1000, - ).toISOString() - : undefined, + typeof pixExpiration === 'number' + ? new Date(pixExpiration * 1000).toISOString() + : undefined, instructionsUrl: qrCode?.hosted_instructions_url ?? undefined, metadata: payment.metadata ?? undefined, method: 'pix' as const, @@ -148,3 +190,102 @@ export function mapStripePixIntent( status, }; } + +function mapStripeCouponDiscount(coupon: StripeCoupon) { + if (typeof coupon.percent_off === 'number') { + return { + type: 'percentage' as const, + value: coupon.percent_off, + }; + } + + const amounts = coupon.currency_options + ? Object.fromEntries( + Object.entries(coupon.currency_options) + .filter(([, value]) => typeof value.amount_off === 'number') + .map(([currency, value]) => [ + currency.toUpperCase(), + value.amount_off!, + ]), + ) + : undefined; + const firstAmount = amounts ? Object.values(amounts)[0] : undefined; + const value = + typeof coupon.amount_off === 'number' + ? coupon.amount_off + : (firstAmount ?? 0); + + return { + type: 'fixed' as const, + value, + currency: coupon.currency?.toUpperCase() ?? undefined, + amounts: amounts && Object.keys(amounts).length > 0 ? amounts : undefined, + }; +} + +function getStripeCouponStatus( + promotionCode: StripePromotionCode, + coupon: StripeCoupon, +): BaseCoupon['status'] { + if (typeof promotionCode.expires_at === 'number') { + const expiresAt = new Date(promotionCode.expires_at * 1000).getTime(); + if (expiresAt <= Date.now()) { + return 'expired'; + } + } + + if (promotionCode.active === false || coupon.valid === false) { + return 'inactive'; + } + + return 'active'; +} + +export function mapStripePromotionCode( + promotionCode: StripePromotionCode, + coupon: StripeCoupon, + sandbox: boolean, + statusOverride?: BaseCoupon['status'], +): BaseCoupon { + const promotionExpiresAt = + typeof promotionCode.expires_at === 'number' + ? new Date(promotionCode.expires_at * 1000).toISOString() + : undefined; + const couponExpiresAt = + typeof coupon.redeem_by === 'number' + ? new Date(coupon.redeem_by * 1000).toISOString() + : undefined; + + return { + id: promotionCode.id, + provider: 'stripe', + sandbox, + code: promotionCode.code ?? coupon.id, + name: coupon.name ?? promotionCode.code ?? undefined, + status: statusOverride ?? getStripeCouponStatus(promotionCode, coupon), + active: promotionCode.active, + discount: mapStripeCouponDiscount(coupon), + duration: coupon.duration + ? { + type: coupon.duration, + durationInMonths: coupon.duration_in_months ?? undefined, + } + : undefined, + expiresAt: promotionExpiresAt ?? couponExpiresAt, + customerId: promotionCode.customer ?? undefined, + firstTimeOnly: + promotionCode.restrictions?.first_time_transaction ?? undefined, + minimumAmount: promotionCode.restrictions?.minimum_amount ?? undefined, + minimumAmountCurrency: + promotionCode.restrictions?.minimum_amount_currency?.toUpperCase() ?? + undefined, + appliesTo: coupon.applies_to?.products?.length + ? { products: coupon.applies_to.products.filter(Boolean) as string[] } + : undefined, + redemptions: { + count: promotionCode.times_redeemed ?? coupon.times_redeemed ?? 0, + max: promotionCode.max_redemptions ?? coupon.max_redemptions ?? null, + }, + metadata: coupon.metadata ?? undefined, + }; +} diff --git a/packages/stripe/src/shared/sync.ts b/packages/stripe/src/shared/sync.ts index c106bbb..6934c7c 100644 --- a/packages/stripe/src/shared/sync.ts +++ b/packages/stripe/src/shared/sync.ts @@ -110,7 +110,7 @@ export const syncStripeSubscription = async ({ await database.repositories.subscriptions.upsert(schema, event); - return event.data as unknown as Record; + return event.data; }; export const syncStripePayment = async ({ diff --git a/packages/stripe/src/shared/webhooks.ts b/packages/stripe/src/shared/webhooks.ts new file mode 100644 index 0000000..c279fab --- /dev/null +++ b/packages/stripe/src/shared/webhooks.ts @@ -0,0 +1,123 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import type { + PaymeshEvent, + ProviderWebhookHandleOptions, + ProviderWebhookHandleResult, +} from 'paymesh'; +import { withRaw } from 'paymesh'; +import type { + StripeCoupon, + StripeCustomer, + StripeDeletedCustomer, + StripeEvent, + StripePaymentObject, + StripePromotionCode, +} from '../types'; +import { STRIPE_EVENTS, STRIPE_HOOKS } from './constants'; +import { + mapStripeCustomer, + mapStripeDeletedCustomer, + mapStripePaymentObject, + mapStripePromotionCode, +} from './mapper'; + +export async function verifyStripeWebhook( + request: Request, + webhookSecret?: string, +) { + if (!webhookSecret) return false; + + const signature = new URLSearchParams( + request.headers.get('stripe-signature')?.replaceAll(',', '&'), + ); + const timestamp = signature.get('t'); + const expected = signature.get('v1'); + + if (!timestamp || !expected) return false; + + const actual = createHmac('sha256', webhookSecret) + .update(`${timestamp}.${await request.text()}`) + .digest('hex'); + + return ( + actual.length === expected.length && + timingSafeEqual(Buffer.from(actual), Buffer.from(expected)) + ); +} + +export async function handleStripeWebhook( + options: ProviderWebhookHandleOptions, + resolveProviderSandbox: () => boolean, + resolvePromotionCoupon: ( + promotionCode: StripePromotionCode, + ) => Promise, +): Promise> { + const payload = await options.request.json(); + + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new TypeError('Stripe webhook payload must be a JSON object.'); + } + + const event = payload as StripeEvent; + const type = STRIPE_EVENTS[event.type] ?? 'payment.created'; + const sandbox = + typeof event.livemode === 'boolean' + ? !event.livemode + : resolveProviderSandbox(); + const object = event.data?.object; + + let data: unknown = payload; + + if (object) { + switch (type) { + case 'customer.deleted': + data = withRaw( + mapStripeDeletedCustomer(object as StripeDeletedCustomer, sandbox), + object, + options.includeRaw, + ); + break; + case 'customer.created': + case 'customer.updated': + data = withRaw( + mapStripeCustomer(object as StripeCustomer, sandbox), + object, + options.includeRaw, + ); + break; + case 'coupon.created': + case 'coupon.updated': { + const promotionCode = object as StripePromotionCode; + const coupon = await resolvePromotionCoupon(promotionCode); + data = withRaw( + mapStripePromotionCode(promotionCode, coupon, sandbox), + { coupon, promotionCode }, + options.includeRaw, + ); + break; + } + default: + data = withRaw( + mapStripePaymentObject(object as StripePaymentObject, sandbox), + object, + options.includeRaw, + ); + } + } + + return { + deliveryId: event.id, + hook: STRIPE_HOOKS[type], + event: withRaw( + { + id: event.id, + type, + provider: 'stripe', + sandbox, + data, + }, + payload, + options.includeRaw, + ) as PaymeshEvent, + }; +} diff --git a/packages/stripe/src/types.ts b/packages/stripe/src/types.ts index 5da182d..1d86795 100644 --- a/packages/stripe/src/types.ts +++ b/packages/stripe/src/types.ts @@ -168,12 +168,7 @@ export interface StripeEvent { livemode?: boolean; type: string; data?: { - object?: - | StripeCheckoutSession - | StripePaymentIntent - | StripeCharge - | StripeCustomer - | StripeDeletedCustomer; + object?: StripeWebhookObject; }; } @@ -185,6 +180,59 @@ export type StripePaymentObject = | StripePaymentIntent | StripeCharge; +export interface StripeCouponCurrencyOption { + amount_off?: number | null; +} + +export interface StripeCoupon { + id: string; + object?: 'coupon'; + amount_off?: number | null; + applies_to?: { + products?: string[] | null; + } | null; + currency?: string | null; + currency_options?: Record | null; + duration?: 'once' | 'forever' | 'repeating' | null; + duration_in_months?: number | null; + max_redemptions?: number | null; + metadata?: Record | null; + name?: string | null; + percent_off?: number | null; + redeem_by?: number | null; + times_redeemed?: number | null; + valid?: boolean; +} + +export interface StripePromotionCodeRestrictions { + first_time_transaction?: boolean | null; + minimum_amount?: number | null; + minimum_amount_currency?: string | null; +} + +export interface StripePromotionCode { + id: string; + object?: 'promotion_code'; + active?: boolean; + code?: string | null; + coupon: string | StripeCoupon; + customer?: string | null; + expires_at?: number | null; + livemode?: boolean; + max_redemptions?: number | null; + metadata?: Record | null; + restrictions?: StripePromotionCodeRestrictions | null; + times_redeemed?: number | null; +} + +export type StripeWebhookObject = + | StripeCheckoutSession + | StripePaymentIntent + | StripeCharge + | StripeCustomer + | StripeDeletedCustomer + | StripePromotionCode; + /** * Generic Stripe list response wrapper. */ diff --git a/packages/stripe/test/stripe.test.ts b/packages/stripe/test/stripe.test.ts index cc55a40..2aa3c94 100644 --- a/packages/stripe/test/stripe.test.ts +++ b/packages/stripe/test/stripe.test.ts @@ -81,6 +81,305 @@ describe('stripe provider', () => { expect(payment.raw).toBeNull(); }); + test('applies couponCode and allowCouponCodes in checkout sessions', async () => { + const provider = stripe({ + secret: 'sk_test_123', + baseUrl: 'https://stripe.test', + fetch: (async (input, init) => { + if (String(input).includes('/v1/promotion_codes?')) { + return Response.json({ + object: 'list', + data: [ + { + id: 'promo_abc', + code: 'WELCOME10', + active: true, + coupon: { + id: 'coupon_abc', + percent_off: 10, + duration: 'once', + valid: true, + times_redeemed: 0, + }, + }, + ], + has_more: false, + }); + } + + const body = init?.body as URLSearchParams; + expect(String(input)).toBe('https://stripe.test/v1/checkout/sessions'); + expect(body.get('allow_promotion_codes')).toBe('true'); + expect(body.get('discounts[0][promotion_code]')).toBe('promo_abc'); + + return Response.json({ + id: 'cs_test_coupon', + object: 'checkout.session', + amount_total: 1000, + currency: 'usd', + payment_status: 'unpaid', + status: 'open', + }); + }) as typeof fetch, + }); + + await provider.payments.create({ + amount: 1000, + currency: 'USD', + couponCode: 'WELCOME10', + allowCouponCodes: true, + }); + }); + + test('creates and checks Stripe coupons', async () => { + const provider = stripe({ + secret: 'sk_test_123', + baseUrl: 'https://stripe.coupons.test', + fetch: (async (input, init) => { + if (String(input) === 'https://stripe.coupons.test/v1/coupons') { + const body = init?.body as URLSearchParams; + expect(body.get('percent_off')).toBe('10'); + expect(body.get('duration')).toBe('once'); + expect(body.get('name')).toBe('Welcome'); + return Response.json({ + id: 'coupon_abc', + percent_off: 10, + duration: 'once', + name: 'Welcome', + valid: true, + times_redeemed: 0, + }); + } + if ( + String(input) === 'https://stripe.coupons.test/v1/promotion_codes' + ) { + const body = init?.body as URLSearchParams; + expect(body.get('code')).toBe('WELCOME10'); + expect(body.get('coupon')).toBe('coupon_abc'); + return Response.json({ + id: 'promo_abc', + code: 'WELCOME10', + active: true, + coupon: 'coupon_abc', + times_redeemed: 0, + }); + } + if ( + String(input) === 'https://stripe.coupons.test/v1/coupons/coupon_abc' + ) { + return Response.json({ + id: 'coupon_abc', + percent_off: 10, + duration: 'once', + name: 'Welcome', + valid: true, + times_redeemed: 0, + }); + } + if (String(input).includes('/v1/promotion_codes?code=WELCOME10')) { + return Response.json({ + object: 'list', + data: [ + { + id: 'promo_abc', + code: 'WELCOME10', + active: true, + times_redeemed: 0, + coupon: { + id: 'coupon_abc', + percent_off: 10, + duration: 'once', + name: 'Welcome', + valid: true, + times_redeemed: 0, + }, + }, + ], + has_more: false, + }); + } + + throw new Error(`Unexpected Stripe request: ${String(input)}`); + }) as typeof fetch, + }); + + const coupon = await provider.coupons?.create({ + code: 'WELCOME10', + name: 'Welcome', + discount: { type: 'percentage', value: 10 }, + duration: { type: 'once' }, + }); + const check = await provider.coupons?.check({ + code: 'WELCOME10', + amount: 1000, + currency: 'USD', + }); + + expect(coupon).toMatchObject({ + id: 'promo_abc', + code: 'WELCOME10', + status: 'active', + }); + expect(check).toMatchObject({ + valid: true, + preview: { + subtotal: 1000, + discountTotal: 100, + total: 900, + currency: 'USD', + }, + }); + }); + + test('archives and deletes Stripe coupons with distinct semantics', async () => { + const calls: string[] = []; + const provider = stripe({ + secret: 'sk_test_123', + baseUrl: 'https://stripe.coupons.test', + fetch: (async (input, init) => { + const url = String(input); + calls.push(`${init?.method ?? 'GET'} ${url}`); + + if ( + url === 'https://stripe.coupons.test/v1/promotion_codes/promo_abc' && + init?.method === 'POST' + ) { + const body = init.body as URLSearchParams; + expect(body.get('active')).toBe('false'); + return Response.json({ + id: 'promo_abc', + code: 'WELCOME10', + active: false, + coupon: 'coupon_abc', + }); + } + if ( + url === + 'https://stripe.coupons.test/v1/promotion_codes/promo_abc?expand[]=coupon' + ) { + return Response.json({ + id: 'promo_abc', + code: 'WELCOME10', + active: true, + coupon: { + id: 'coupon_abc', + percent_off: 10, + duration: 'once', + valid: true, + times_redeemed: 0, + }, + }); + } + if (url === 'https://stripe.coupons.test/v1/coupons/coupon_abc') { + if (init?.method === 'GET') { + return Response.json({ + id: 'coupon_abc', + percent_off: 10, + duration: 'once', + valid: true, + times_redeemed: 0, + }); + } + + expect(init?.method).toBe('DELETE'); + return Response.json({ id: 'coupon_abc', deleted: true }); + } + if ( + url === 'https://stripe.coupons.test/v1/promotion_codes/promo_abc' + ) { + return Response.json({ + id: 'promo_abc', + code: 'WELCOME10', + active: true, + coupon: 'coupon_abc', + }); + } + + throw new Error(`Unexpected Stripe request: ${url}`); + }) as typeof fetch, + }); + + const archived = await provider.coupons?.archive?.('promo_abc'); + const deleted = await provider.coupons?.delete('promo_abc'); + + expect(archived).toMatchObject({ + id: 'promo_abc', + code: 'WELCOME10', + status: 'archived', + active: false, + }); + expect(deleted).toMatchObject({ + id: 'promo_abc', + deleted: true, + code: 'WELCOME10', + }); + expect(calls).toEqual([ + 'POST https://stripe.coupons.test/v1/promotion_codes/promo_abc', + 'GET https://stripe.coupons.test/v1/coupons/coupon_abc', + 'GET https://stripe.coupons.test/v1/promotion_codes/promo_abc?expand[]=coupon', + 'POST https://stripe.coupons.test/v1/promotion_codes/promo_abc', + 'DELETE https://stripe.coupons.test/v1/coupons/coupon_abc', + ]); + }); + + test('maps Stripe promotion code webhooks to coupon events', async () => { + const provider = stripe({ + secret: 'sk_test_123', + baseUrl: 'https://stripe.webhooks.test', + fetch: (async (input) => { + expect(String(input)).toBe( + 'https://stripe.webhooks.test/v1/coupons/coupon_abc', + ); + return Response.json({ + id: 'coupon_abc', + percent_off: 10, + duration: 'once', + valid: true, + times_redeemed: 1, + }); + }) as typeof fetch, + }); + + const handled = await provider.webhooks?.handle({ + request: new Request('https://app.test/webhooks', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + id: 'evt_coupon', + type: 'promotion_code.updated', + livemode: false, + data: { + object: { + id: 'promo_abc', + code: 'WELCOME10', + active: true, + times_redeemed: 1, + coupon: 'coupon_abc', + }, + }, + }), + }), + }); + + expect(handled?.hook).toBe('onCouponUpdated'); + expect(handled?.event).toMatchObject({ + id: 'evt_coupon', + type: 'coupon.updated', + provider: 'stripe', + sandbox: true, + data: { + id: 'promo_abc', + code: 'WELCOME10', + status: 'active', + redemptions: { + count: 1, + }, + }, + }); + }); + test('uses client request options when creating payments', async () => { let attempts = 0; let timeoutSignal: AbortSignal | undefined; From a869e1661813735d9a6cabbe3e660ad56403b96c Mon Sep 17 00:00:00 2001 From: almeida Date: Mon, 15 Jun 2026 10:22:23 -0300 Subject: [PATCH 15/15] fix: resolve coderabbitai comments --- apps/web/content/docs/concepts/coupons.mdx | 4 +- apps/web/public/llms.txt | 1 + apps/web/src/lib/docs.tsx | 10 +- packages/dodo/src/index.ts | 7 + packages/dodo/src/shared/constants.ts | 1 - packages/dodo/src/shared/sync.ts | 18 +- packages/dodo/src/shared/utils.ts | 18 ++ packages/dodo/src/shared/webhooks.ts | 25 +- packages/dodo/test/dodo.test.ts | 32 ++- packages/dodo/test/webhooks.test.ts | 50 ++++ packages/drizzle/src/repositories.ts | 5 + packages/drizzle/src/shared/cursor.ts | 5 +- packages/memory/src/repositories.ts | 4 + packages/memory/src/shared/cursor.ts | 5 +- packages/paymesh/src/client/coupons.ts | 4 +- packages/paymesh/src/types/client.ts | 2 +- packages/paymesh/src/types/database.ts | 3 + packages/paymesh/test/client.test.ts | 308 +++++++++++++++++++-- packages/postgres/src/repositories.ts | 5 + packages/postgres/src/shared/cursor.ts | 5 +- packages/prisma/src/repositories.ts | 5 + packages/prisma/src/shared/cursor.ts | 5 +- 22 files changed, 471 insertions(+), 51 deletions(-) diff --git a/apps/web/content/docs/concepts/coupons.mdx b/apps/web/content/docs/concepts/coupons.mdx index 62c21f9..4f8b4ed 100644 --- a/apps/web/content/docs/concepts/coupons.mdx +++ b/apps/web/content/docs/concepts/coupons.mdx @@ -565,7 +565,7 @@ Important details: - Stripe updates are intentionally narrow in the current implementation; only safe fields such as `active` and `metadata` are updated - first-time-only restrictions may cause `check()` to return `provider_error` when Stripe cannot be validated locally with strong guarantees -See [Stripe](/home/almeida/Documents/Projects/paymesh/paymesh/apps/web/content/docs/providers/stripe.mdx). +See [Stripe](/docs/providers/stripe). ### Polar @@ -579,7 +579,7 @@ Important details: - `appliesTo.prices` is not supported and is rejected - some Polar accounts may require `organizationId` in provider config for discount APIs -See [Polar](/home/almeida/Documents/Projects/paymesh/paymesh/apps/web/content/docs/providers/polar.mdx). +See [Polar](/docs/providers/polar). ## Error behavior diff --git a/apps/web/public/llms.txt b/apps/web/public/llms.txt index 6e07f04..4fd5834 100644 --- a/apps/web/public/llms.txt +++ b/apps/web/public/llms.txt @@ -451,6 +451,7 @@ Supported commands: - [Stripe](/docs/providers/stripe) - [Polar](/docs/providers/polar) - [AbacatePay](/docs/providers/abacatepay) +- [Dodo](/docs/providers/dodo) ### Adapters diff --git a/apps/web/src/lib/docs.tsx b/apps/web/src/lib/docs.tsx index 3ecb46f..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; @@ -2589,7 +2597,7 @@ const docsPages: DocPage[] = [ state stay inside one integration boundary. diff --git a/packages/dodo/src/index.ts b/packages/dodo/src/index.ts index f4df809..6c20f2a 100644 --- a/packages/dodo/src/index.ts +++ b/packages/dodo/src/index.ts @@ -301,6 +301,13 @@ export const dodo = ({ 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); diff --git a/packages/dodo/src/shared/constants.ts b/packages/dodo/src/shared/constants.ts index 2ad15c4..a1928ca 100644 --- a/packages/dodo/src/shared/constants.ts +++ b/packages/dodo/src/shared/constants.ts @@ -42,7 +42,6 @@ export const DODO_EVENTS: Record = { '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', diff --git a/packages/dodo/src/shared/sync.ts b/packages/dodo/src/shared/sync.ts index 0780930..d7141ae 100644 --- a/packages/dodo/src/shared/sync.ts +++ b/packages/dodo/src/shared/sync.ts @@ -54,17 +54,14 @@ export const syncDodoPayment = async ({ const normalizedPayment = mapDodoPayment(payment, sandbox); const normalized = withRaw(normalizedPayment, payment, true); - const promises = [database.repositories.invoices.upsert(schema, normalized)]; + await database.repositories.invoices.upsert(schema, normalized); - if ('method' in normalizedPayment && normalizedPayment.method === 'pix') - promises.push( - database.repositories.pix.upsert( - schema, - normalized as Extract, - ), + if ('method' in normalizedPayment && normalizedPayment.method === 'pix') { + await database.repositories.pix.upsert( + schema, + normalized as Extract, ); - - await Promise.all(promises); + } return normalized; }; @@ -110,6 +107,5 @@ export const syncDodoSubscription = async ({ ); await database.repositories.subscriptions.upsert(schema, event); - - return event.data as unknown as Record; + return event.data; }; diff --git a/packages/dodo/src/shared/utils.ts b/packages/dodo/src/shared/utils.ts index 1763b67..c430f53 100644 --- a/packages/dodo/src/shared/utils.ts +++ b/packages/dodo/src/shared/utils.ts @@ -171,6 +171,13 @@ export function ensureDodoProductIds(productIds?: string[]) { 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: { @@ -188,6 +195,17 @@ export function buildDodoProductCart(input: { 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, diff --git a/packages/dodo/src/shared/webhooks.ts b/packages/dodo/src/shared/webhooks.ts index 74987e8..f8dfa58 100644 --- a/packages/dodo/src/shared/webhooks.ts +++ b/packages/dodo/src/shared/webhooks.ts @@ -1,5 +1,5 @@ import type { PaymeshEventType } from 'paymesh'; -import { withRaw } from 'paymesh'; +import { PaymeshError, withRaw } from 'paymesh'; import type { DodoWebhookPayload } from '../types'; import { DODO_EVENTS, DODO_HOOKS } from './constants'; import { @@ -50,11 +50,30 @@ function isDodoSubscriptionWebhook(event: DodoWebhookPayload): event is Extract< | 'subscription.updated'; } > { - return event.type.startsWith('subscription.'); + 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 { - return DODO_EVENTS[type] ?? 'payment.created'; + 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) { diff --git a/packages/dodo/test/dodo.test.ts b/packages/dodo/test/dodo.test.ts index cdd221d..6052630 100644 --- a/packages/dodo/test/dodo.test.ts +++ b/packages/dodo/test/dodo.test.ts @@ -189,7 +189,7 @@ describe('dodo provider', () => { apiKey: 'dodo_test_123', }); - expect( + await expect( provider.payments.create({ customer: { email: 'ana@example.com', @@ -201,7 +201,7 @@ describe('dodo provider', () => { 'Provider "dodo" requires at least one product id in "productIds"', }); - expect( + await expect( provider.payments.create({ productIds: ['prod_1', 'prod_2'], amount: 1000, @@ -215,7 +215,7 @@ describe('dodo provider', () => { 'Provider "dodo" only accepts "amount" when exactly one product id is provided.', }); - expect( + await expect( provider.payments.create({ productIds: ['prod_123'], }), @@ -224,6 +224,32 @@ describe('dodo provider', () => { 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', () => { diff --git a/packages/dodo/test/webhooks.test.ts b/packages/dodo/test/webhooks.test.ts index d9d7211..dc38548 100644 --- a/packages/dodo/test/webhooks.test.ts +++ b/packages/dodo/test/webhooks.test.ts @@ -177,6 +177,56 @@ describe('dodo webhooks', () => { 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) { diff --git a/packages/drizzle/src/repositories.ts b/packages/drizzle/src/repositories.ts index f75de5e..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 }>({ @@ -454,6 +455,10 @@ export function createRepositories( 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, 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 10d3f9f..f701d8c 100644 --- a/packages/memory/src/repositories.ts +++ b/packages/memory/src/repositories.ts @@ -152,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( @@ -321,6 +322,9 @@ export function createRepositories( 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), 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/paymesh/src/client/coupons.ts b/packages/paymesh/src/client/coupons.ts index 8e39dd6..f815470 100644 --- a/packages/paymesh/src/client/coupons.ts +++ b/packages/paymesh/src/client/coupons.ts @@ -122,6 +122,7 @@ export function createCouponsClient< before: options?.before, code: options?.code, active: options?.active, + status: options?.status, }, ); @@ -138,6 +139,7 @@ export function createCouponsClient< before: options?.before, code: options?.code, active: options?.active, + status: options?.status, }, mergeOptions({ includeRaw: resolveIncludeRaw( @@ -208,7 +210,7 @@ export function createCouponsClient< mergeOptions(requestOptions), ); - if (coupons) await coupons.markDeleted(schema, result); + if (coupons && result.deleted) await coupons.markDeleted(schema, result); return result; }, diff --git a/packages/paymesh/src/types/client.ts b/packages/paymesh/src/types/client.ts index 3657253..9cd4c3a 100644 --- a/packages/paymesh/src/types/client.ts +++ b/packages/paymesh/src/types/client.ts @@ -342,7 +342,7 @@ export type PaymeshCouponCreateData< export type PaymeshCouponUpdateData< Schema extends DatabaseSchemaOptions = DatabaseSchemaOptions, > = Simplify< - CouponUpdateData & DatabaseTableInputExtraFields + CouponUpdateData & Partial> >; /** Paymesh coupon output with schema-derived extra fields. */ diff --git a/packages/paymesh/src/types/database.ts b/packages/paymesh/src/types/database.ts index 8672f62..ce96a0d 100644 --- a/packages/paymesh/src/types/database.ts +++ b/packages/paymesh/src/types/database.ts @@ -9,6 +9,7 @@ import type { BasePaymeshEvent, BasePix, Coupon, + CouponStatus, Customer, Pix, ProviderCatalogPrice, @@ -439,6 +440,8 @@ export interface PaymeshCouponListOptions { 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. */ diff --git a/packages/paymesh/test/client.test.ts b/packages/paymesh/test/client.test.ts index c467585..f5a6a55 100644 --- a/packages/paymesh/test/client.test.ts +++ b/packages/paymesh/test/client.test.ts @@ -265,6 +265,262 @@ describe('client', () => { 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 }), @@ -1666,6 +1922,7 @@ function createStubProvider({ onCouponCreate, onCouponGet, onCouponCheck, + onCouponList, onCouponUpdate, onCouponArchive, onCouponDelete, @@ -1708,6 +1965,15 @@ function createStubProvider({ data: CouponCheckData, options?: ProviderRequestOptions, ) => Promise>; + onCouponList?: ( + options?: Record, + requestOptions?: ProviderRequestOptions, + ) => Promise<{ + data: Coupon[]; + next: string | null; + previous: string | null; + total: number; + }>; onCouponUpdate?: ( id: string, data: Record, @@ -1783,26 +2049,28 @@ function createStubProvider({ { id: 'raw_coupon_123' }, options?.includeRaw, ), - list: async (_options, options) => ({ - data: [ - withRaw( - { - id: 'promo_123', - provider: 'stub', - sandbox, - code: 'WELCOME10', - status: 'active' as const, - discount: { type: 'percentage' as const, value: 10 }, - redemptions: { count: 0, max: null }, - }, - { id: 'raw_coupon_123' }, - options?.includeRaw, - ), - ], - total: 1, - previous: null, - next: null, - }), + list: onCouponList + ? onCouponList + : async (_options, options) => ({ + data: [ + withRaw( + { + id: 'promo_123', + provider: 'stub', + sandbox, + code: 'WELCOME10', + status: 'active' as const, + discount: { type: 'percentage' as const, value: 10 }, + redemptions: { count: 0, max: null }, + }, + { id: 'raw_coupon_123' }, + options?.includeRaw, + ), + ], + total: 1, + previous: null, + next: null, + }), update: async (_id, _data, options) => onCouponUpdate ? onCouponUpdate(_id, _data as Record, options) diff --git a/packages/postgres/src/repositories.ts b/packages/postgres/src/repositories.ts index 6899a64..aee30a8 100644 --- a/packages/postgres/src/repositories.ts +++ b/packages/postgres/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 }>({ @@ -454,6 +455,10 @@ export function createRepositories( 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, diff --git a/packages/postgres/src/shared/cursor.ts b/packages/postgres/src/shared/cursor.ts index ad34ab8..79b7b58 100644 --- a/packages/postgres/src/shared/cursor.ts +++ b/packages/postgres/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/prisma/src/repositories.ts b/packages/prisma/src/repositories.ts index 6f4080e..9cc6e03 100644 --- a/packages/prisma/src/repositories.ts +++ b/packages/prisma/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 }>({ @@ -454,6 +455,10 @@ export function createRepositories( 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, diff --git a/packages/prisma/src/shared/cursor.ts b/packages/prisma/src/shared/cursor.ts index ad34ab8..79b7b58 100644 --- a/packages/prisma/src/shared/cursor.ts +++ b/packages/prisma/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`, }); } }