From dcc1dc6e0aabccea409653011ab0729998dc7967 Mon Sep 17 00:00:00 2001 From: Prashant Varma Date: Mon, 27 Jul 2026 18:43:19 +0530 Subject: [PATCH] feat: integrate Dodo Payments for subscription management, checkout, and webhooks --- .env.example | 8 + app/api/payments/checkout/route.ts | 54 +++++ app/api/payments/portal/route.ts | 49 +++++ app/api/webhooks/dodo/route.ts | 94 ++++++++ app/dashboard/Sidebar.tsx | 15 +- app/dashboard/billing/BillingClient.tsx | 278 ++++++++++++++++++++++++ app/dashboard/billing/page.tsx | 40 ++++ bun.lock | 19 ++ lib/db/schema.ts | 6 + lib/dodopayments.ts | 10 + package.json | 3 + supabase/migration.sql | 9 + 12 files changed, 581 insertions(+), 4 deletions(-) create mode 100644 app/api/payments/checkout/route.ts create mode 100644 app/api/payments/portal/route.ts create mode 100644 app/api/webhooks/dodo/route.ts create mode 100644 app/dashboard/billing/BillingClient.tsx create mode 100644 app/dashboard/billing/page.tsx create mode 100644 lib/dodopayments.ts diff --git a/.env.example b/.env.example index c6a84b7..c23cdf3 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,11 @@ NEXT_PUBLIC_APP_URL=https://gatherline.vercel.app # Any long random string; Vercel Cron sends it as a Bearer token CRON_SECRET=change-me + +# Dodo Payments Configuration +DODO_PAYMENTS_API_KEY=dodo_sk_test_... +DODO_PAYMENTS_WEBHOOK_KEY=dodo_whsec_... +DODO_PAYMENTS_ENVIRONMENT=test_mode # 'test_mode' or 'live_mode' +NEXT_PUBLIC_DODO_PAYMENTS_ENVIRONMENT=test_mode +DODO_PRO_PRODUCT_ID=p_... # Product ID created in Dodo Payments Dashboard + diff --git a/app/api/payments/checkout/route.ts b/app/api/payments/checkout/route.ts new file mode 100644 index 0000000..c6e3470 --- /dev/null +++ b/app/api/payments/checkout/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { dodoPayments } from "@/lib/dodopayments"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: Request) { + try { + const session = await auth(); + if (!session?.user?.id || !session.user.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json().catch(() => ({})); + const productId = body.productId || process.env.DODO_PRO_PRODUCT_ID; + + if (!productId) { + return NextResponse.json( + { error: "Product ID is required. Please set DODO_PRO_PRODUCT_ID in your environment variables." }, + { status: 400 } + ); + } + + const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; + + const checkoutSession = await dodoPayments.checkoutSessions.create({ + product_cart: [ + { + product_id: productId, + quantity: 1, + }, + ], + customer: { + email: session.user.email, + name: session.user.name || undefined, + }, + return_url: `${appUrl}/dashboard/billing?payment=success`, + metadata: { + userId: session.user.id, + }, + }); + + return NextResponse.json({ + checkoutUrl: checkoutSession.checkout_url, + sessionId: checkoutSession.session_id, + }); + } catch (error: any) { + console.error("Dodo Payments checkout error:", error); + return NextResponse.json( + { error: error?.message || "Failed to create checkout session" }, + { status: 500 } + ); + } +} diff --git a/app/api/payments/portal/route.ts b/app/api/payments/portal/route.ts new file mode 100644 index 0000000..f063d0e --- /dev/null +++ b/app/api/payments/portal/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { users } from "@/lib/db/schema"; +import { eq } from "drizzle-orm"; +import { dodoPayments } from "@/lib/dodopayments"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const [user] = await db + .select() + .from(users) + .where(eq(users.id, session.user.id)) + .limit(1); + + if (!user || (!user.dodoCustomerId && !user.subscriptionId)) { + return NextResponse.json( + { error: "No active customer subscription found" }, + { status: 404 } + ); + } + + // Generate Customer Portal session via Dodo Payments SDK if available, or direct customer URL + if (user.dodoCustomerId && (dodoPayments as any).customers?.createPortalSession) { + const portalSession = await (dodoPayments as any).customers.createPortalSession({ + customer_id: user.dodoCustomerId, + }); + return NextResponse.json({ portalUrl: portalSession.url }); + } + + // Direct dashboard fallback link + return NextResponse.json({ + portalUrl: `https://test.checkout.dodopayments.com/customer-portal`, + }); + } catch (error: any) { + console.error("Dodo Payments portal error:", error); + return NextResponse.json( + { error: error?.message || "Failed to generate portal link" }, + { status: 500 } + ); + } +} diff --git a/app/api/webhooks/dodo/route.ts b/app/api/webhooks/dodo/route.ts new file mode 100644 index 0000000..7d9d31b --- /dev/null +++ b/app/api/webhooks/dodo/route.ts @@ -0,0 +1,94 @@ +import { Webhooks } from "@dodopayments/nextjs"; +import { db } from "@/lib/db"; +import { users } from "@/lib/db/schema"; +import { eq } from "drizzle-orm"; + +export const dynamic = "force-dynamic"; + +const webhookKey = + process.env.DODO_PAYMENTS_WEBHOOK_KEY || + "whsec_dGVzdF9zZWNyZXRfa2V5X2Zvcl9idWlsZF8xMjM0NTY="; + +export const POST = Webhooks({ + webhookKey, + onPayload: async (payload: any) => { + const { type, data } = payload; + console.log(`[Dodo Payments Webhook] Processing event: ${type}`); + + if (!data) return; + + const userId = data?.metadata?.userId || data?.customer?.metadata?.userId; + const customerId = data?.customer?.customer_id || data?.customer_id; + const subscriptionId = + data?.subscription_id || (type.startsWith("subscription") ? data?.id : undefined); + const productId = data?.product_id || data?.items?.[0]?.product_id; + + switch (type) { + case "subscription.active": + case "subscription.created": + case "subscription.renewed": + case "payment.succeeded": { + const periodEnd = data?.next_billing_date + ? new Date(data.next_billing_date) + : data?.expires_at + ? new Date(data.expires_at) + : null; + + if (userId) { + await db + .update(users) + .set({ + dodoCustomerId: customerId || undefined, + subscriptionId: subscriptionId || undefined, + subscriptionStatus: "active", + subscriptionProductId: productId || undefined, + subscriptionCurrentPeriodEnd: periodEnd, + }) + .where(eq(users.id, userId)); + } else if (customerId) { + await db + .update(users) + .set({ + subscriptionId: subscriptionId || undefined, + subscriptionStatus: "active", + subscriptionProductId: productId || undefined, + subscriptionCurrentPeriodEnd: periodEnd, + }) + .where(eq(users.dodoCustomerId, customerId)); + } + break; + } + + case "subscription.cancelled": + case "subscription.expired": + case "subscription.failed": + case "payment.failed": { + const status = type.includes("cancelled") + ? "cancelled" + : type.includes("expired") + ? "expired" + : "past_due"; + + if (userId) { + await db + .update(users) + .set({ + subscriptionStatus: status, + }) + .where(eq(users.id, userId)); + } else if (customerId) { + await db + .update(users) + .set({ + subscriptionStatus: status, + }) + .where(eq(users.dodoCustomerId, customerId)); + } + break; + } + + default: + console.log(`[Dodo Payments Webhook] Unhandled event type: ${type}`); + } + }, +}); diff --git a/app/dashboard/Sidebar.tsx b/app/dashboard/Sidebar.tsx index 10148cb..4fcee6c 100644 --- a/app/dashboard/Sidebar.tsx +++ b/app/dashboard/Sidebar.tsx @@ -65,12 +65,19 @@ export default function Sidebar({ Active Requests -
+ setMobileOpen(false)} + className={`w-full flex items-center gap-2.5 px-3 py-1.5 rounded-[6px] text-[13px] transition-colors focus-ring ${pathname === "/dashboard/billing" + ? "text-[#171717] bg-[#EBEBEB] font-medium" + : "text-[#4D4D4D] hover:text-[#171717] hover:bg-[#EBEBEB]" + }`} + > - + - Templates -
+ Billing +
diff --git a/app/dashboard/billing/BillingClient.tsx b/app/dashboard/billing/BillingClient.tsx new file mode 100644 index 0000000..4a36375 --- /dev/null +++ b/app/dashboard/billing/BillingClient.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useSearchParams } from "next/navigation"; + +interface BillingClientProps { + user: { + id: string; + email: string; + name?: string | null; + subscriptionStatus?: string | null; + subscriptionCurrentPeriodEnd?: string | null; + }; +} + +export default function BillingClient({ user }: BillingClientProps) { + const searchParams = useSearchParams(); + const paymentSuccess = searchParams.get("payment") === "success"; + const [loading, setLoading] = useState(false); + const [portalLoading, setPortalLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const isPro = user.subscriptionStatus === "active"; + + useEffect(() => { + // Dynamically initialize Dodo Payments checkout overlay SDK if available + try { + import("dodopayments-checkout").then(({ DodoPayments }) => { + DodoPayments.Initialize({ + mode: process.env.NEXT_PUBLIC_DODO_PAYMENTS_ENVIRONMENT === "live_mode" ? "live" : "test", + onEvent: (event) => { + console.log("Dodo Payments overlay event:", event); + }, + }); + }).catch(() => { + // SDK optional fallback to direct URL redirect + }); + } catch (e) { + console.warn("Could not load dodopayments-checkout", e); + } + }, []); + + async function handleCheckout() { + setLoading(true); + setErrorMessage(null); + try { + const res = await fetch("/api/payments/checkout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Failed to start checkout"); + } + + if (data.checkoutUrl) { + // Try opening with overlay SDK first if available, else redirect + try { + const { DodoPayments } = await import("dodopayments-checkout"); + DodoPayments.Checkout.open({ checkoutUrl: data.checkoutUrl }); + } catch { + window.location.href = data.checkoutUrl; + } + } + } catch (err: any) { + setErrorMessage(err.message || "Something went wrong. Please try again."); + } finally { + setLoading(false); + } + } + + async function handleManageSubscription() { + setPortalLoading(true); + setErrorMessage(null); + try { + const res = await fetch("/api/payments/portal"); + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Failed to load portal"); + } + + if (data.portalUrl) { + window.location.href = data.portalUrl; + } + } catch (err: any) { + setErrorMessage(err.message || "Unable to open subscription portal."); + } finally { + setPortalLoading(false); + } + } + + return ( +
+ {/* Banner Notice */} + {paymentSuccess && ( +
+
+ + Payment Successful! + Thank you for subscribing to Gatherline Pro. +
+
+ )} + + {errorMessage && ( +
+ {errorMessage} +
+ )} + + {/* Header */} +
+

Billing & Plans

+

+ Manage your subscription plan, billing details, and payment method via Dodo Payments. +

+
+ + {/* Current Plan Overview Card */} +
+
+
+
+ Current Plan +
+
+ + {isPro ? "Gatherline Pro" : "Gatherline Free"} + + + {isPro ? "Active Subscriber" : "Free Plan"} + +
+
+ + {isPro && ( + + )} +
+ + {isPro && user.subscriptionCurrentPeriodEnd && ( +
+ Your plan automatically renews on{" "} + + {new Date(user.subscriptionCurrentPeriodEnd).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + })} + +
+ )} +
+ + {/* Plan Tier Selection */} +
+ {/* Free Card */} +
+
+
+

Starter

+ Free forever +
+
+ $0 / month +
+
    +
  • + + + + Up to 3 Active Client Requests +
  • +
  • + + + + Standard Email Notifications +
  • +
  • + + + + No Custom Branding +
  • +
+
+ + +
+ + {/* Pro Card */} +
+
+ Recommended +
+ +
+
+

Pro Plan

+ Billed monthly +
+
+ $29 / month +
+
    +
  • + + + + Unlimited Active Requests +
  • +
  • + + + + Custom Workspace Branding & Logo +
  • +
  • + + + + Automated Client Reminder Schedules +
  • +
  • + + + + Priority Customer Support +
  • +
+
+ + +
+
+ + {/* Footer Security Notice */} +
+
+ + + + Payments securely processed by Dodo Payments Merchant of Record. Tax included automatically. +
+
+
+ ); +} diff --git a/app/dashboard/billing/page.tsx b/app/dashboard/billing/page.tsx new file mode 100644 index 0000000..cba023b --- /dev/null +++ b/app/dashboard/billing/page.tsx @@ -0,0 +1,40 @@ +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { users } from "@/lib/db/schema"; +import { eq } from "drizzle-orm"; +import BillingClient from "./BillingClient"; + +export default async function BillingPage() { + const session = await auth(); + + if (!session?.user?.id) { + redirect("/login"); + } + + const [userRecord] = await db + .select({ + id: users.id, + email: users.email, + name: users.name, + subscriptionStatus: users.subscriptionStatus, + subscriptionCurrentPeriodEnd: users.subscriptionCurrentPeriodEnd, + }) + .from(users) + .where(eq(users.id, session.user.id)) + .limit(1); + + return ( + + ); +} diff --git a/bun.lock b/bun.lock index ad8ff50..f9bd070 100644 --- a/bun.lock +++ b/bun.lock @@ -5,8 +5,11 @@ "name": "gatherline", "dependencies": { "@auth/drizzle-adapter": "^1.11.3", + "@dodopayments/nextjs": "^0.3.7", "@supabase/ssr": "^0.5.2", "@supabase/supabase-js": "^2.45.0", + "dodopayments": "^2.43.0", + "dodopayments-checkout": "^1.9.5", "drizzle-orm": "^0.45.2", "next": "14.2.15", "next-auth": "^5.0.0-beta.32", @@ -35,6 +38,10 @@ "@auth/drizzle-adapter": ["@auth/drizzle-adapter@1.11.3", "", { "dependencies": { "@auth/core": "0.41.3" } }, "sha512-TxqVasPVuf7LDAT1Yuu6bftpuet8o0tjdXW+mXpnXWdSRPQsomdzMoz9RsXkFN/JfdK+/DwdgOEmOTv1gbiFNw=="], + "@dodopayments/core": ["@dodopayments/core@0.3.13", "", { "dependencies": { "dodopayments": "^2.42.2", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-s7c3V5p0P31GCmmC1zyE/N7KK2FNB/PZdD/D6ZnMM8glGM3bdzZGFXjFzchMvwRyefeUz+FBRKSw9tbc/tXhVg=="], + + "@dodopayments/nextjs": ["@dodopayments/nextjs@0.3.7", "", { "dependencies": { "@dodopayments/core": "^0.3.13" }, "peerDependencies": { "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-X3BDTeJh0/Oo8GbVg12OPNcKaVkclIpyjqO0pDzadCbC/7Isy/1Ytem03uv8ntqiQyg0hjycDQZXQ2H1H77svw=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], @@ -133,6 +140,8 @@ "@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + "@supabase/auth-js": ["@supabase/auth-js@2.110.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-Mi288WCTp6wxMFCOu/UgzgHEXODjdl2uVTLqK11eanzGZaldU3RyP8Am+ZbNuVzFP+5+iOvppxzv7N5Ym84xTg=="], "@supabase/functions-js": ["@supabase/functions-js@2.110.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-Fde5wlY8ZZy+9yqrWlQHo8MacSyUBArBEtN2boB4thJQigPnQD/cc61qZN0n3I1L0gwhWtHYwIMnOBKxSvF6Hw=="], @@ -205,6 +214,10 @@ "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + "dodopayments": ["dodopayments@2.43.0", "", { "dependencies": { "standardwebhooks": "^1.0.0" }, "bin": { "dodopayments": "bin/cli" } }, "sha512-svlPEf2H6Zp4SFKsW+ylL3ssBsupG67O6nEW77Md0XUIr1A21wmnyw0PlUjolUCMPNXMuYnsqpVatgyuZytYiA=="], + + "dodopayments-checkout": ["dodopayments-checkout@1.9.5", "", {}, "sha512-TRWdXdvwjzL5uNp8518v/1E3zvZf8P+Is5/QVRvHRxqSbIiCrxFqetEfromOaVRqNe7XLE23wWwxj0ccPCEBug=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -231,6 +244,8 @@ "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -373,6 +388,8 @@ "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], + "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], "styled-jsx": ["styled-jsx@5.1.1", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" } }, "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw=="], @@ -405,6 +422,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], diff --git a/lib/db/schema.ts b/lib/db/schema.ts index c9f4781..97bf5f7 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -17,6 +17,12 @@ export const users = pgTable("users", { email: text("email").unique(), emailVerified: timestamp("emailVerified", { mode: "date" }), image: text("image"), + // Dodo Payments subscription fields + dodoCustomerId: text("dodo_customer_id"), + subscriptionId: text("subscription_id"), + subscriptionStatus: text("subscription_status").default("free"), + subscriptionProductId: text("subscription_product_id"), + subscriptionCurrentPeriodEnd: timestamp("subscription_current_period_end", { mode: "date" }), }); export const accounts = pgTable( diff --git a/lib/dodopayments.ts b/lib/dodopayments.ts new file mode 100644 index 0000000..a9ad512 --- /dev/null +++ b/lib/dodopayments.ts @@ -0,0 +1,10 @@ +import DodoPayments from "dodopayments"; + +if (!process.env.DODO_PAYMENTS_API_KEY && process.env.NODE_ENV === "production") { + console.warn("DODO_PAYMENTS_API_KEY environment variable is not set."); +} + +export const dodoPayments = new DodoPayments({ + bearerToken: process.env.DODO_PAYMENTS_API_KEY || "", + environment: (process.env.DODO_PAYMENTS_ENVIRONMENT as "test_mode" | "live_mode") || "test_mode", +}); diff --git a/package.json b/package.json index 5a225a3..51a338c 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,11 @@ }, "dependencies": { "@auth/drizzle-adapter": "^1.11.3", + "@dodopayments/nextjs": "^0.3.7", "@supabase/ssr": "^0.5.2", "@supabase/supabase-js": "^2.45.0", + "dodopayments": "^2.43.0", + "dodopayments-checkout": "^1.9.5", "drizzle-orm": "^0.45.2", "next": "14.2.15", "next-auth": "^5.0.0-beta.32", diff --git a/supabase/migration.sql b/supabase/migration.sql index 6a3736e..faa7bfe 100644 --- a/supabase/migration.sql +++ b/supabase/migration.sql @@ -66,3 +66,12 @@ CREATE POLICY "service_role_users" ON public.users FOR ALL USING (true) WITH CHE CREATE POLICY "service_role_accounts" ON public.accounts FOR ALL USING (true) WITH CHECK (true); CREATE POLICY "service_role_sessions" ON public.sessions FOR ALL USING (true) WITH CHECK (true); CREATE POLICY "service_role_verification_tokens" ON public.verification_tokens FOR ALL USING (true) WITH CHECK (true); + +-- 7. Add Dodo Payments subscription tracking columns to public.users +ALTER TABLE public.users + ADD COLUMN IF NOT EXISTS dodo_customer_id TEXT, + ADD COLUMN IF NOT EXISTS subscription_id TEXT, + ADD COLUMN IF NOT EXISTS subscription_status TEXT DEFAULT 'free', + ADD COLUMN IF NOT EXISTS subscription_product_id TEXT, + ADD COLUMN IF NOT EXISTS subscription_current_period_end TIMESTAMPTZ; +