diff --git a/apps/cli/src/helpers/core/post-installation.ts b/apps/cli/src/helpers/core/post-installation.ts index da32793d4..b2edc7ee8 100644 --- a/apps/cli/src/helpers/core/post-installation.ts +++ b/apps/cli/src/helpers/core/post-installation.ts @@ -107,10 +107,6 @@ export async function displayPostInstallInstructions( : ""; const clerkInstructions = config.auth === "clerk" ? getClerkInstructions(frontend || [], backend, api) : ""; - const polarInstructions = - config.payments === "polar" && config.auth === "better-auth" - ? getPolarInstructions(backend) - : ""; const alchemyDeployInstructions = getAlchemyDeployInstructions( runCmd, webDeploy, @@ -135,6 +131,10 @@ export async function displayPostInstallInstructions( isConvex && config.auth === "better-auth" ? getBetterAuthConvexInstructions(hasWeb ?? false, webPort, packageManager) : ""; + const polarInstructions = + config.payments === "polar" && config.auth === "better-auth" + ? getPolarInstructions(backend, packageManager) + : ""; const bunWebNativeWarning = packageManager === "bun" && hasNative && hasWeb ? getBunWebNativeWarning() : ""; @@ -565,7 +565,22 @@ function getBetterAuthConvexInstructions(hasWeb: boolean, webPort: string, packa ); } -function getPolarInstructions(backend: Backend) { +function getPolarInstructions(backend: Backend, packageManager: string) { + if (backend === "convex") { + const cmd = packageManager === "npm" ? "npx" : packageManager; + return ( + `${pc.bold("Polar Payments Setup:")}\n` + + `${pc.cyan("•")} Create a Polar organization token, webhook secret, and product in ${pc.underline("https://sandbox.polar.sh/")}\n` + + `${pc.cyan("•")} Set the Convex env vars from ${pc.white("packages/backend")}:\n` + + `${pc.white(" cd packages/backend")}\n` + + `${pc.white(` ${cmd} convex env set POLAR_ORGANIZATION_TOKEN=your_polar_token`)}\n` + + `${pc.white(` ${cmd} convex env set POLAR_WEBHOOK_SECRET=your_polar_webhook_secret`)}\n` + + `${pc.white(` ${cmd} convex env set POLAR_PRODUCT_ID_PRO=your_polar_product_id`)}\n` + + `${pc.white(" Optional: set POLAR_SERVER=production when you go live")}\n` + + `${pc.cyan("•")} Configure a Polar webhook to ${pc.white("https:///polar/events")}` + ); + } + const envPath = backend === "self" ? "apps/web/.env" : "apps/server/.env"; return `${pc.bold("Polar Payments Setup:")}\n${pc.cyan("•")} Get access token & product ID from ${pc.underline("https://sandbox.polar.sh/")}\n${pc.cyan("•")} Set POLAR_ACCESS_TOKEN in ${envPath}`; } diff --git a/apps/cli/src/prompts/payments.ts b/apps/cli/src/prompts/payments.ts index 66238bfc6..1898be6bc 100644 --- a/apps/cli/src/prompts/payments.ts +++ b/apps/cli/src/prompts/payments.ts @@ -17,9 +17,7 @@ export async function getPaymentsChoice( } const isPolarCompatible = - auth === "better-auth" && - backend !== "convex" && - (frontends?.length === 0 || splitFrontends(frontends).web.length > 0); + auth === "better-auth" && (frontends?.length === 0 || splitFrontends(frontends).web.length > 0); if (!isPolarCompatible) { return "none" as Payments; diff --git a/apps/cli/test/auth.test.ts b/apps/cli/test/auth.test.ts index 674826eab..d744fb942 100644 --- a/apps/cli/test/auth.test.ts +++ b/apps/cli/test/auth.test.ts @@ -232,6 +232,77 @@ describe("Authentication Configurations", () => { expect(dashboardFile).toContain("Unauthenticated"); }); + it("should scaffold Convex Better Auth with Polar payments", async () => { + const result = await runTRPCTest({ + projectName: "better-auth-convex-polar", + auth: "better-auth", + payments: "polar", + backend: "convex", + runtime: "none", + database: "none", + orm: "none", + api: "none", + frontend: ["tanstack-router"], + addons: ["turborepo"], + examples: ["none"], + dbSetup: "none", + webDeploy: "none", + serverDeploy: "none", + install: false, + }); + + expectSuccess(result); + if (!result.projectDir) { + throw new Error("Expected projectDir to be defined"); + } + + const convexConfigFile = await fs.readFile( + path.join(result.projectDir, "packages/backend/convex/convex.config.ts"), + "utf8", + ); + const httpFile = await fs.readFile( + path.join(result.projectDir, "packages/backend/convex/http.ts"), + "utf8", + ); + const polarFile = await fs.readFile( + path.join(result.projectDir, "packages/backend/convex/polar.ts"), + "utf8", + ); + const dashboardFile = await fs.readFile( + path.join(result.projectDir, "apps/web/src/routes/dashboard.tsx"), + "utf8", + ); + const backendPackageFile = await fs.readFile( + path.join(result.projectDir, "packages/backend/package.json"), + "utf8", + ); + const webPackageFile = await fs.readFile( + path.join(result.projectDir, "apps/web/package.json"), + "utf8", + ); + const convexEnvFile = await fs.readFile( + path.join(result.projectDir, "packages/backend/.env.local"), + "utf8", + ); + + expect(convexConfigFile).toContain('import polar from "@convex-dev/polar/convex.config";'); + expect(convexConfigFile).toContain("app.use(polar);"); + expect(httpFile).toContain('import { polar } from "./polar";'); + expect(httpFile).toContain("polar.registerRoutes(http as any);"); + expect(polarFile).toContain('import { Polar } from "@convex-dev/polar";'); + expect(polarFile).toContain("POLAR_PRODUCT_ID_PRO"); + expect(dashboardFile).toContain('from "@convex-dev/polar/react";'); + expect(dashboardFile).toContain("api.polar.getConfiguredProducts"); + expect(dashboardFile).toContain("api.polar.getCurrentSubscription"); + expect(backendPackageFile).toContain('"@convex-dev/polar"'); + expect(backendPackageFile).toContain('"@polar-sh/sdk"'); + expect(webPackageFile).toContain('"@convex-dev/polar"'); + expect(webPackageFile).toContain('"@polar-sh/checkout"'); + expect(convexEnvFile).toContain("# npx convex env set POLAR_ORGANIZATION_TOKEN"); + expect(convexEnvFile).toContain("# POLAR_PRODUCT_ID_PRO="); + expect(convexEnvFile).toContain("POLAR_SERVER=sandbox"); + }); + const convexUnsupportedFrontends = ["nuxt", "svelte", "solid", "astro"] as const; for (const frontend of convexUnsupportedFrontends) { it(`should fail with Convex Better Auth + ${frontend}`, async () => { diff --git a/apps/web/src/app/(home)/new/_components/utils.ts b/apps/web/src/app/(home)/new/_components/utils.ts index 7113a452c..d9a3de7e3 100644 --- a/apps/web/src/app/(home)/new/_components/utils.ts +++ b/apps/web/src/app/(home)/new/_components/utils.ts @@ -598,14 +598,6 @@ export const analyzeStackCompatibility = (stack: StackState): CompatibilityResul message: "Payments set to 'None' (Polar requires Better Auth)", }); } - if (nextStack.backend === "convex") { - nextStack.payments = "none"; - changed = true; - changes.push({ - category: "payments", - message: "Payments set to 'None' (Polar incompatible with Convex)", - }); - } const hasAnyFrontend = hasWebFrontend(nextStack.webFrontend) || hasNativeFrontend(nextStack.nativeFrontend); if (!hasWebFrontend(nextStack.webFrontend) && hasAnyFrontend) { @@ -795,9 +787,6 @@ export const getDisabledReason = ( return `Convex AI example only supports React-based frontends (not ${frontendName})`; } } - if (category === "payments" && optionId === "polar") { - return "Polar is not compatible with Convex"; - } } // ============================================ diff --git a/packages/template-generator/src/processors/env-vars.ts b/packages/template-generator/src/processors/env-vars.ts index f84b6f604..9441b16b0 100644 --- a/packages/template-generator/src/processors/env-vars.ts +++ b/packages/template-generator/src/processors/env-vars.ts @@ -252,6 +252,7 @@ function buildNativeVars( function buildConvexBackendVars( frontend: string[], auth: ProjectConfig["auth"], + payments: ProjectConfig["payments"], examples: ProjectConfig["examples"], ): EnvVariable[] { const hasNextJs = frontend.includes("next"); @@ -325,12 +326,42 @@ function buildConvexBackendVars( } } + if (payments === "polar") { + vars.push( + { + key: "POLAR_ORGANIZATION_TOKEN", + value: "", + condition: true, + comment: "Polar organization token", + }, + { + key: "POLAR_WEBHOOK_SECRET", + value: "", + condition: true, + comment: "Polar webhook secret", + }, + { + key: "POLAR_PRODUCT_ID_PRO", + value: "", + condition: true, + comment: "Polar product ID for the default Pro plan", + }, + { + key: "POLAR_SERVER", + value: "sandbox", + condition: true, + comment: "Polar environment: sandbox or production", + }, + ); + } + return vars; } function buildConvexCommentBlocks( frontend: string[], auth: ProjectConfig["auth"], + payments: ProjectConfig["payments"], examples: ProjectConfig["examples"], ): string { const hasNative = @@ -372,6 +403,18 @@ function buildConvexCommentBlocks( ${hasWeb || hasNative ? `# npx convex env set SITE_URL ${defaultSiteUrl}\n` : ""}`; } + if (payments === "polar") { + commentBlocks += `# Set Polar environment variables +# npx convex env set POLAR_ORGANIZATION_TOKEN=your_polar_token +# npx convex env set POLAR_WEBHOOK_SECRET=your_polar_webhook_secret +# npx convex env set POLAR_PRODUCT_ID_PRO=your_polar_product_id +# Optional: npx convex env set POLAR_SERVER=sandbox +# Create a Polar webhook at https:///polar/events +# Enable: product.created, product.updated, subscription.created, subscription.updated + +`; + } + return commentBlocks; } @@ -549,7 +592,7 @@ export function processEnvVariables(vfs: VirtualFileSystem, config: ProjectConfi const envLocalPath = `${convexBackendDir}/.env.local`; // Write comment blocks first - const commentBlocks = buildConvexCommentBlocks(frontend, auth, examples); + const commentBlocks = buildConvexCommentBlocks(frontend, auth, payments, examples); if (commentBlocks) { let currentContent = ""; if (vfs.exists(envLocalPath)) { @@ -559,7 +602,7 @@ export function processEnvVariables(vfs: VirtualFileSystem, config: ProjectConfi } // Then add variables - const convexBackendVars = buildConvexBackendVars(frontend, auth, examples); + const convexBackendVars = buildConvexBackendVars(frontend, auth, payments, examples); if (convexBackendVars.length > 0) { let existingContent = ""; if (vfs.exists(envLocalPath)) { diff --git a/packages/template-generator/src/processors/payments-deps.ts b/packages/template-generator/src/processors/payments-deps.ts index 3b35e7501..ac1a90a25 100644 --- a/packages/template-generator/src/processors/payments-deps.ts +++ b/packages/template-generator/src/processors/payments-deps.ts @@ -4,13 +4,39 @@ import type { VirtualFileSystem } from "../core/virtual-fs"; import { addPackageDependency } from "../utils/add-deps"; export function processPaymentsDeps(vfs: VirtualFileSystem, config: ProjectConfig): void { - const { payments, frontend } = config; + const { payments, frontend, backend } = config; if (!payments || payments === "none") return; + const backendPath = "packages/backend/package.json"; const authPath = "packages/auth/package.json"; const webPath = "apps/web/package.json"; if (payments === "polar") { + if (backend === "convex") { + if (vfs.exists(backendPath)) { + addPackageDependency({ + vfs, + packagePath: backendPath, + dependencies: ["@convex-dev/polar", "@polar-sh/sdk"], + }); + } + + if (vfs.exists(webPath)) { + const hasReactWebFrontend = frontend.some((f) => + ["react-router", "tanstack-router", "tanstack-start", "next"].includes(f), + ); + if (hasReactWebFrontend) { + addPackageDependency({ + vfs, + packagePath: webPath, + dependencies: ["@convex-dev/polar", "@polar-sh/checkout"], + }); + } + } + + return; + } + if (vfs.exists(authPath)) { addPackageDependency({ vfs, diff --git a/packages/template-generator/src/template-handlers/payments.ts b/packages/template-generator/src/template-handlers/payments.ts index cdea7b2cf..7c62f2d4f 100644 --- a/packages/template-generator/src/template-handlers/payments.ts +++ b/packages/template-generator/src/template-handlers/payments.ts @@ -9,7 +9,6 @@ export async function processPaymentsTemplates( config: ProjectConfig, ): Promise { if (!config.payments || config.payments === "none") return; - if (config.backend === "convex") return; const hasReactWeb = config.frontend.some((f) => ["tanstack-router", "react-router", "tanstack-start", "next"].includes(f), @@ -18,7 +17,15 @@ export async function processPaymentsTemplates( const hasSvelteWeb = config.frontend.includes("svelte"); const hasSolidWeb = config.frontend.includes("solid"); - if (config.backend !== "none") { + if (config.backend === "convex") { + processTemplatesFromPrefix( + vfs, + templates, + `payments/${config.payments}/convex/backend`, + "packages/backend", + config, + ); + } else if (config.backend !== "none") { processTemplatesFromPrefix( vfs, templates, @@ -40,6 +47,16 @@ export async function processPaymentsTemplates( "apps/web", config, ); + + if (config.backend === "convex") { + processTemplatesFromPrefix( + vfs, + templates, + `payments/${config.payments}/convex/web/react/${reactFramework}`, + "apps/web", + config, + ); + } } } else if (hasNuxtWeb) { processTemplatesFromPrefix( diff --git a/packages/template-generator/src/templates.generated.ts b/packages/template-generator/src/templates.generated.ts index 9f6bc7170..45cace22f 100644 --- a/packages/template-generator/src/templates.generated.ts +++ b/packages/template-generator/src/templates.generated.ts @@ -2081,6 +2081,9 @@ export const getCurrentUser = query({ `], ["auth/better-auth/convex/backend/convex/http.ts.hbs", `import { httpRouter } from "convex/server"; import { authComponent, createAuth } from "./auth"; +{{#if (eq payments "polar")}} +import { polar } from "./polar"; +{{/if}} const http = httpRouter(); @@ -2096,6 +2099,10 @@ authComponent.registerRoutes(http, createAuth, { cors: true }); {{else}} authComponent.registerRoutes(http, createAuth); {{/if}} +{{#if (eq payments "polar")}} + +polar.registerRoutes(http as any); +{{/if}} export default http; `], @@ -3416,6 +3423,10 @@ export const { GET, POST } = handler; import SignInForm from "@/components/sign-in-form"; import SignUpForm from "@/components/sign-up-form"; import UserMenu from "@/components/user-menu"; +{{#if (eq payments "polar")}} +import { CheckoutLink, CustomerPortalLink } from "@convex-dev/polar/react"; +import { buttonVariants } from "@{{projectName}}/ui/components/button"; +{{/if}} import { api } from "@{{projectName}}/backend/convex/_generated/api"; import { Authenticated, @@ -3425,18 +3436,58 @@ import { } from "convex/react"; import { useState } from "react"; +function DashboardContent() { + const privateData = useQuery(api.privateData.get); + {{#if (eq payments "polar")}} + const products = useQuery(api.polar.getConfiguredProducts); + const subscription = useQuery(api.polar.getCurrentSubscription); + + const proProduct = products?.pro; + const hasProSubscription = subscription?.productKey === "pro"; + {{/if}} + + return ( +
+

Dashboard

+

privateData: {privateData?.message}

+ {{#if (eq payments "polar")}} +

Plan: {hasProSubscription ? "Pro" : "Free"}

+ {products === undefined || subscription === undefined ? ( +

Loading subscription options...

+ ) : proProduct ? ( + hasProSubscription ? ( + + Manage Subscription + + ) : ( + + Upgrade to Pro + + ) + ) : ( +

Set POLAR_PRODUCT_ID_PRO in packages/backend/.env.local to enable checkout.

+ )} + {{/if}} + +
+ ); +} + export default function DashboardPage() { const [showSignIn, setShowSignIn] = useState(false); - const privateData = useQuery(api.privateData.get); return ( <> -
-

Dashboard

-

privateData: {privateData?.message}

- -
+
{showSignIn ? ( @@ -4162,6 +4213,10 @@ export const authClient = createAuthClient({ ["auth/better-auth/convex/web/react/react-router/src/routes/dashboard.tsx.hbs", `import SignInForm from "@/components/sign-in-form"; import SignUpForm from "@/components/sign-up-form"; import UserMenu from "@/components/user-menu"; +{{#if (eq payments "polar")}} +import { CheckoutLink, CustomerPortalLink } from "@convex-dev/polar/react"; +import { buttonVariants } from "@{{projectName}}/ui/components/button"; +{{/if}} import { api } from "@{{projectName}}/backend/convex/_generated/api"; import { Authenticated, @@ -4173,11 +4228,44 @@ import { useState } from "react"; function PrivateDashboardContent() { const privateData = useQuery(api.privateData.get); + {{#if (eq payments "polar")}} + const products = useQuery(api.polar.getConfiguredProducts); + const subscription = useQuery(api.polar.getCurrentSubscription); + + const proProduct = products?.pro; + const hasProSubscription = subscription?.productKey === "pro"; + {{/if}} return (

Dashboard

privateData: {privateData?.message}

+ {{#if (eq payments "polar")}} +

Plan: {hasProSubscription ? "Pro" : "Free"}

+ {products === undefined || subscription === undefined ? ( +

Loading subscription options...

+ ) : proProduct ? ( + hasProSubscription ? ( + + Manage Subscription + + ) : ( + + Upgrade to Pro + + ) + ) : ( +

Set POLAR_PRODUCT_ID_PRO in packages/backend/.env.local to enable checkout.

+ )} + {{/if}}
); @@ -4566,6 +4654,10 @@ export const authClient = createAuthClient({ ["auth/better-auth/convex/web/react/tanstack-router/src/routes/dashboard.tsx.hbs", `import SignInForm from "@/components/sign-in-form"; import SignUpForm from "@/components/sign-up-form"; import UserMenu from "@/components/user-menu"; +{{#if (eq payments "polar")}} +import { CheckoutLink, CustomerPortalLink } from "@convex-dev/polar/react"; +import { buttonVariants } from "@{{projectName}}/ui/components/button"; +{{/if}} import { api } from "@{{projectName}}/backend/convex/_generated/api"; import { createFileRoute } from "@tanstack/react-router"; import { @@ -4582,11 +4674,44 @@ export const Route = createFileRoute("/dashboard")({ function PrivateDashboardContent() { const privateData = useQuery(api.privateData.get); + {{#if (eq payments "polar")}} + const products = useQuery(api.polar.getConfiguredProducts); + const subscription = useQuery(api.polar.getCurrentSubscription); + + const proProduct = products?.pro; + const hasProSubscription = subscription?.productKey === "pro"; + {{/if}} return (

Dashboard

privateData: {privateData?.message}

+ {{#if (eq payments "polar")}} +

Plan: {hasProSubscription ? "Pro" : "Free"}

+ {products === undefined || subscription === undefined ? ( +

Loading subscription options...

+ ) : proProduct ? ( + hasProSubscription ? ( + + Manage Subscription + + ) : ( + + Upgrade to Pro + + ) + ) : ( +

Set POLAR_PRODUCT_ID_PRO in packages/backend/.env.local to enable checkout.

+ )} + {{/if}}
); @@ -4990,6 +5115,10 @@ export const Route = createFileRoute("/api/auth/$")({ ["auth/better-auth/convex/web/react/tanstack-start/src/routes/dashboard.tsx.hbs", `import SignInForm from "@/components/sign-in-form"; import SignUpForm from "@/components/sign-up-form"; import UserMenu from "@/components/user-menu"; +{{#if (eq payments "polar")}} +import { CheckoutLink, CustomerPortalLink } from "@convex-dev/polar/react"; +import { buttonVariants } from "@{{projectName}}/ui/components/button"; +{{/if}} import { api } from "@{{projectName}}/backend/convex/_generated/api"; import { createFileRoute } from "@tanstack/react-router"; import { @@ -5004,18 +5133,58 @@ export const Route = createFileRoute("/dashboard")({ component: RouteComponent, }); +function PrivateDashboardContent() { + const privateData = useQuery(api.privateData.get); + {{#if (eq payments "polar")}} + const products = useQuery(api.polar.getConfiguredProducts); + const subscription = useQuery(api.polar.getCurrentSubscription); + + const proProduct = products?.pro; + const hasProSubscription = subscription?.productKey === "pro"; + {{/if}} + + return ( +
+

Dashboard

+

privateData: {privateData?.message}

+ {{#if (eq payments "polar")}} +

Plan: {hasProSubscription ? "Pro" : "Free"}

+ {products === undefined || subscription === undefined ? ( +

Loading subscription options...

+ ) : proProduct ? ( + hasProSubscription ? ( + + Manage Subscription + + ) : ( + + Upgrade to Pro + + ) + ) : ( +

Set POLAR_PRODUCT_ID_PRO in packages/backend/.env.local to enable checkout.

+ )} + {{/if}} + +
+ ); +} + function RouteComponent() { const [showSignIn, setShowSignIn] = useState(false); - const privateData = useQuery(api.privateData.get); return ( <> -
-

Dashboard

-

privateData: {privateData?.message}

- -
+
{showSignIn ? ( @@ -13073,6 +13242,9 @@ export const startInstance = createStart(() => { {{#if (eq auth "better-auth")}} import betterAuth from "@convex-dev/better-auth/convex.config"; {{/if}} +{{#if (eq payments "polar")}} +import polar from "@convex-dev/polar/convex.config"; +{{/if}} {{#if (includes examples "ai")}} import agent from "@convex-dev/agent/convex.config"; {{/if}} @@ -13081,6 +13253,9 @@ const app = defineApp(); {{#if (eq auth "better-auth")}} app.use(betterAuth); {{/if}} +{{#if (eq payments "polar")}} +app.use(polar); +{{/if}} {{#if (includes examples "ai")}} app.use(agent); {{/if}} @@ -29476,6 +29651,73 @@ export function cn(...inputs: ClassValue[]) { "include": ["src/**/*.ts", "src/**/*.tsx"], "exclude": ["node_modules"] } +`], + ["payments/polar/convex/backend/convex/polar.ts.hbs", `import { Polar } from "@convex-dev/polar"; + +import { api, components } from "./_generated/api"; +import type { DataModel } from "./_generated/dataModel"; +import { action, query } from "./_generated/server"; + +export const polar = new Polar(components.polar, { + getUserInfo: async (ctx) => { + const user = await ctx.runQuery(api.auth.getCurrentUser); + + if (!user) { + throw new Error("Not authenticated"); + } + + if (!user.email) { + throw new Error("Authenticated user is missing an email address"); + } + + return { + userId: user._id, + email: user.email, + }; + }, + products: { + pro: process.env.POLAR_PRODUCT_ID_PRO || "your-product-id", + }, + server: (process.env.POLAR_SERVER as "sandbox" | "production" | undefined) ?? "sandbox", +}); + +export const { + changeCurrentSubscription, + cancelCurrentSubscription, + getConfiguredProducts, + listAllProducts, + listAllSubscriptions, + generateCheckoutLink, + generateCustomerPortalUrl, +} = polar.api(); + +export const getCurrentSubscription = query({ + args: {}, + handler: async (ctx) => { + const user = await ctx.runQuery(api.auth.getCurrentUser); + + if (!user) { + return null; + } + + return await polar.getCurrentSubscription(ctx, { + userId: user._id, + }); + }, +}); + +export const syncProducts = action({ + args: {}, + handler: async (ctx) => { + await polar.syncProducts(ctx); + }, +}); +`], + ["payments/polar/convex/web/react/tanstack-start/src/functions/get-payment.ts.hbs", `import { createServerFn } from "@tanstack/react-start"; + +export const getPayment = createServerFn({ method: "GET" }).handler(async () => { + return null; +}); `], ["payments/polar/server/base/src/lib/payments.ts.hbs", `import { Polar } from "@polar-sh/sdk"; import { env } from "@{{projectName}}/env/server"; @@ -29622,4 +29864,4 @@ function SuccessPage() { `] ]); -export const TEMPLATE_COUNT = 462; +export const TEMPLATE_COUNT = 464; diff --git a/packages/template-generator/src/utils/add-deps.ts b/packages/template-generator/src/utils/add-deps.ts index 61b686bab..fc606bc11 100644 --- a/packages/template-generator/src/utils/add-deps.ts +++ b/packages/template-generator/src/utils/add-deps.ts @@ -117,6 +117,7 @@ export const dependencyVersionMap = { convex: "^1.33.1", "@convex-dev/react-query": "^0.1.0", "@convex-dev/agent": "^0.3.2", + "@convex-dev/polar": "^0.9.0", "convex-svelte": "^0.0.12", "convex-nuxt": "0.1.5", "convex-vue": "^0.1.5", @@ -158,6 +159,7 @@ export const dependencyVersionMap = { "@t3-oss/env-nuxt": "^0.13.1", "@polar-sh/better-auth": "^1.8.3", + "@polar-sh/checkout": "^0.2.0", "@polar-sh/sdk": "^0.42.2", } as const; diff --git a/packages/template-generator/templates/auth/better-auth/convex/backend/convex/http.ts.hbs b/packages/template-generator/templates/auth/better-auth/convex/backend/convex/http.ts.hbs index be0961ef3..95748dce3 100644 --- a/packages/template-generator/templates/auth/better-auth/convex/backend/convex/http.ts.hbs +++ b/packages/template-generator/templates/auth/better-auth/convex/backend/convex/http.ts.hbs @@ -1,5 +1,8 @@ import { httpRouter } from "convex/server"; import { authComponent, createAuth } from "./auth"; +{{#if (eq payments "polar")}} +import { polar } from "./polar"; +{{/if}} const http = httpRouter(); @@ -15,5 +18,9 @@ authComponent.registerRoutes(http, createAuth, { cors: true }); {{else}} authComponent.registerRoutes(http, createAuth); {{/if}} +{{#if (eq payments "polar")}} + +polar.registerRoutes(http as any); +{{/if}} export default http; diff --git a/packages/template-generator/templates/auth/better-auth/convex/web/react/next/src/app/dashboard/page.tsx.hbs b/packages/template-generator/templates/auth/better-auth/convex/web/react/next/src/app/dashboard/page.tsx.hbs index 7e65c074d..b9889b6be 100644 --- a/packages/template-generator/templates/auth/better-auth/convex/web/react/next/src/app/dashboard/page.tsx.hbs +++ b/packages/template-generator/templates/auth/better-auth/convex/web/react/next/src/app/dashboard/page.tsx.hbs @@ -3,6 +3,10 @@ import SignInForm from "@/components/sign-in-form"; import SignUpForm from "@/components/sign-up-form"; import UserMenu from "@/components/user-menu"; +{{#if (eq payments "polar")}} +import { CheckoutLink, CustomerPortalLink } from "@convex-dev/polar/react"; +import { buttonVariants } from "@{{projectName}}/ui/components/button"; +{{/if}} import { api } from "@{{projectName}}/backend/convex/_generated/api"; import { Authenticated, @@ -12,18 +16,58 @@ import { } from "convex/react"; import { useState } from "react"; +function DashboardContent() { + const privateData = useQuery(api.privateData.get); + {{#if (eq payments "polar")}} + const products = useQuery(api.polar.getConfiguredProducts); + const subscription = useQuery(api.polar.getCurrentSubscription); + + const proProduct = products?.pro; + const hasProSubscription = subscription?.productKey === "pro"; + {{/if}} + + return ( +
+

Dashboard

+

privateData: {privateData?.message}

+ {{#if (eq payments "polar")}} +

Plan: {hasProSubscription ? "Pro" : "Free"}

+ {products === undefined || subscription === undefined ? ( +

Loading subscription options...

+ ) : proProduct ? ( + hasProSubscription ? ( + + Manage Subscription + + ) : ( + + Upgrade to Pro + + ) + ) : ( +

Set POLAR_PRODUCT_ID_PRO in packages/backend/.env.local to enable checkout.

+ )} + {{/if}} + +
+ ); +} + export default function DashboardPage() { const [showSignIn, setShowSignIn] = useState(false); - const privateData = useQuery(api.privateData.get); return ( <> -
-

Dashboard

-

privateData: {privateData?.message}

- -
+
{showSignIn ? ( diff --git a/packages/template-generator/templates/auth/better-auth/convex/web/react/react-router/src/routes/dashboard.tsx.hbs b/packages/template-generator/templates/auth/better-auth/convex/web/react/react-router/src/routes/dashboard.tsx.hbs index 2a4ee1c09..061daa18e 100644 --- a/packages/template-generator/templates/auth/better-auth/convex/web/react/react-router/src/routes/dashboard.tsx.hbs +++ b/packages/template-generator/templates/auth/better-auth/convex/web/react/react-router/src/routes/dashboard.tsx.hbs @@ -1,6 +1,10 @@ import SignInForm from "@/components/sign-in-form"; import SignUpForm from "@/components/sign-up-form"; import UserMenu from "@/components/user-menu"; +{{#if (eq payments "polar")}} +import { CheckoutLink, CustomerPortalLink } from "@convex-dev/polar/react"; +import { buttonVariants } from "@{{projectName}}/ui/components/button"; +{{/if}} import { api } from "@{{projectName}}/backend/convex/_generated/api"; import { Authenticated, @@ -12,11 +16,44 @@ import { useState } from "react"; function PrivateDashboardContent() { const privateData = useQuery(api.privateData.get); + {{#if (eq payments "polar")}} + const products = useQuery(api.polar.getConfiguredProducts); + const subscription = useQuery(api.polar.getCurrentSubscription); + + const proProduct = products?.pro; + const hasProSubscription = subscription?.productKey === "pro"; + {{/if}} return (

Dashboard

privateData: {privateData?.message}

+ {{#if (eq payments "polar")}} +

Plan: {hasProSubscription ? "Pro" : "Free"}

+ {products === undefined || subscription === undefined ? ( +

Loading subscription options...

+ ) : proProduct ? ( + hasProSubscription ? ( + + Manage Subscription + + ) : ( + + Upgrade to Pro + + ) + ) : ( +

Set POLAR_PRODUCT_ID_PRO in packages/backend/.env.local to enable checkout.

+ )} + {{/if}}
); diff --git a/packages/template-generator/templates/auth/better-auth/convex/web/react/tanstack-router/src/routes/dashboard.tsx.hbs b/packages/template-generator/templates/auth/better-auth/convex/web/react/tanstack-router/src/routes/dashboard.tsx.hbs index 9a08eb348..71a41a1ac 100644 --- a/packages/template-generator/templates/auth/better-auth/convex/web/react/tanstack-router/src/routes/dashboard.tsx.hbs +++ b/packages/template-generator/templates/auth/better-auth/convex/web/react/tanstack-router/src/routes/dashboard.tsx.hbs @@ -1,6 +1,10 @@ import SignInForm from "@/components/sign-in-form"; import SignUpForm from "@/components/sign-up-form"; import UserMenu from "@/components/user-menu"; +{{#if (eq payments "polar")}} +import { CheckoutLink, CustomerPortalLink } from "@convex-dev/polar/react"; +import { buttonVariants } from "@{{projectName}}/ui/components/button"; +{{/if}} import { api } from "@{{projectName}}/backend/convex/_generated/api"; import { createFileRoute } from "@tanstack/react-router"; import { @@ -17,11 +21,44 @@ export const Route = createFileRoute("/dashboard")({ function PrivateDashboardContent() { const privateData = useQuery(api.privateData.get); + {{#if (eq payments "polar")}} + const products = useQuery(api.polar.getConfiguredProducts); + const subscription = useQuery(api.polar.getCurrentSubscription); + + const proProduct = products?.pro; + const hasProSubscription = subscription?.productKey === "pro"; + {{/if}} return (

Dashboard

privateData: {privateData?.message}

+ {{#if (eq payments "polar")}} +

Plan: {hasProSubscription ? "Pro" : "Free"}

+ {products === undefined || subscription === undefined ? ( +

Loading subscription options...

+ ) : proProduct ? ( + hasProSubscription ? ( + + Manage Subscription + + ) : ( + + Upgrade to Pro + + ) + ) : ( +

Set POLAR_PRODUCT_ID_PRO in packages/backend/.env.local to enable checkout.

+ )} + {{/if}}
); diff --git a/packages/template-generator/templates/auth/better-auth/convex/web/react/tanstack-start/src/routes/dashboard.tsx.hbs b/packages/template-generator/templates/auth/better-auth/convex/web/react/tanstack-start/src/routes/dashboard.tsx.hbs index c5faeaf08..71a41a1ac 100644 --- a/packages/template-generator/templates/auth/better-auth/convex/web/react/tanstack-start/src/routes/dashboard.tsx.hbs +++ b/packages/template-generator/templates/auth/better-auth/convex/web/react/tanstack-start/src/routes/dashboard.tsx.hbs @@ -1,6 +1,10 @@ import SignInForm from "@/components/sign-in-form"; import SignUpForm from "@/components/sign-up-form"; import UserMenu from "@/components/user-menu"; +{{#if (eq payments "polar")}} +import { CheckoutLink, CustomerPortalLink } from "@convex-dev/polar/react"; +import { buttonVariants } from "@{{projectName}}/ui/components/button"; +{{/if}} import { api } from "@{{projectName}}/backend/convex/_generated/api"; import { createFileRoute } from "@tanstack/react-router"; import { @@ -15,18 +19,58 @@ export const Route = createFileRoute("/dashboard")({ component: RouteComponent, }); +function PrivateDashboardContent() { + const privateData = useQuery(api.privateData.get); + {{#if (eq payments "polar")}} + const products = useQuery(api.polar.getConfiguredProducts); + const subscription = useQuery(api.polar.getCurrentSubscription); + + const proProduct = products?.pro; + const hasProSubscription = subscription?.productKey === "pro"; + {{/if}} + + return ( +
+

Dashboard

+

privateData: {privateData?.message}

+ {{#if (eq payments "polar")}} +

Plan: {hasProSubscription ? "Pro" : "Free"}

+ {products === undefined || subscription === undefined ? ( +

Loading subscription options...

+ ) : proProduct ? ( + hasProSubscription ? ( + + Manage Subscription + + ) : ( + + Upgrade to Pro + + ) + ) : ( +

Set POLAR_PRODUCT_ID_PRO in packages/backend/.env.local to enable checkout.

+ )} + {{/if}} + +
+ ); +} + function RouteComponent() { const [showSignIn, setShowSignIn] = useState(false); - const privateData = useQuery(api.privateData.get); return ( <> -
-

Dashboard

-

privateData: {privateData?.message}

- -
+
{showSignIn ? ( diff --git a/packages/template-generator/templates/backend/convex/packages/backend/convex/convex.config.ts.hbs b/packages/template-generator/templates/backend/convex/packages/backend/convex/convex.config.ts.hbs index 12f69d403..0adb2df36 100644 --- a/packages/template-generator/templates/backend/convex/packages/backend/convex/convex.config.ts.hbs +++ b/packages/template-generator/templates/backend/convex/packages/backend/convex/convex.config.ts.hbs @@ -2,6 +2,9 @@ import { defineApp } from "convex/server"; {{#if (eq auth "better-auth")}} import betterAuth from "@convex-dev/better-auth/convex.config"; {{/if}} +{{#if (eq payments "polar")}} +import polar from "@convex-dev/polar/convex.config"; +{{/if}} {{#if (includes examples "ai")}} import agent from "@convex-dev/agent/convex.config"; {{/if}} @@ -10,6 +13,9 @@ const app = defineApp(); {{#if (eq auth "better-auth")}} app.use(betterAuth); {{/if}} +{{#if (eq payments "polar")}} +app.use(polar); +{{/if}} {{#if (includes examples "ai")}} app.use(agent); {{/if}} diff --git a/packages/template-generator/templates/payments/polar/convex/backend/convex/polar.ts.hbs b/packages/template-generator/templates/payments/polar/convex/backend/convex/polar.ts.hbs new file mode 100644 index 000000000..49c91c470 --- /dev/null +++ b/packages/template-generator/templates/payments/polar/convex/backend/convex/polar.ts.hbs @@ -0,0 +1,60 @@ +import { Polar } from "@convex-dev/polar"; + +import { api, components } from "./_generated/api"; +import type { DataModel } from "./_generated/dataModel"; +import { action, query } from "./_generated/server"; + +export const polar = new Polar(components.polar, { + getUserInfo: async (ctx) => { + const user = await ctx.runQuery(api.auth.getCurrentUser); + + if (!user) { + throw new Error("Not authenticated"); + } + + if (!user.email) { + throw new Error("Authenticated user is missing an email address"); + } + + return { + userId: user._id, + email: user.email, + }; + }, + products: { + pro: process.env.POLAR_PRODUCT_ID_PRO || "your-product-id", + }, + server: (process.env.POLAR_SERVER as "sandbox" | "production" | undefined) ?? "sandbox", +}); + +export const { + changeCurrentSubscription, + cancelCurrentSubscription, + getConfiguredProducts, + listAllProducts, + listAllSubscriptions, + generateCheckoutLink, + generateCustomerPortalUrl, +} = polar.api(); + +export const getCurrentSubscription = query({ + args: {}, + handler: async (ctx) => { + const user = await ctx.runQuery(api.auth.getCurrentUser); + + if (!user) { + return null; + } + + return await polar.getCurrentSubscription(ctx, { + userId: user._id, + }); + }, +}); + +export const syncProducts = action({ + args: {}, + handler: async (ctx) => { + await polar.syncProducts(ctx); + }, +}); diff --git a/packages/template-generator/templates/payments/polar/convex/web/react/tanstack-start/src/functions/get-payment.ts.hbs b/packages/template-generator/templates/payments/polar/convex/web/react/tanstack-start/src/functions/get-payment.ts.hbs new file mode 100644 index 000000000..1f2761352 --- /dev/null +++ b/packages/template-generator/templates/payments/polar/convex/web/react/tanstack-start/src/functions/get-payment.ts.hbs @@ -0,0 +1,5 @@ +import { createServerFn } from "@tanstack/react-start"; + +export const getPayment = createServerFn({ method: "GET" }).handler(async () => { + return null; +});