Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

54 changes: 54 additions & 0 deletions app/api/payments/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
49 changes: 49 additions & 0 deletions app/api/payments/portal/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
94 changes: 94 additions & 0 deletions app/api/webhooks/dodo/route.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
},
});
15 changes: 11 additions & 4 deletions app/dashboard/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,19 @@ export default function Sidebar({
<span>Active Requests</span>
</Link>

<div className="w-full flex items-center gap-2.5 px-3 py-1.5 rounded-[6px] text-[13px] text-[#8F8F8F] cursor-not-allowed">
<Link
href="/dashboard/billing"
onClick={() => 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]"
}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M7 15h1m4 0h1m-7 4h12a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span>Templates</span>
</div>
<span>Billing</span>
</Link>

<div className="w-full flex items-center gap-2.5 px-3 py-1.5 rounded-[6px] text-[13px] text-[#8F8F8F] cursor-not-allowed">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
Expand Down
Loading