gaurv007 commited on
Commit
fbf3514
Β·
verified Β·
1 Parent(s): 9d9d922

Replace Stripe with Razorpay: subscriptions, webhooks, checkout modal, INR pricing

Browse files
web/.env.example CHANGED
@@ -4,19 +4,17 @@ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=eyJ...
4
  SUPABASE_SERVICE_ROLE_KEY=eyJ...
5
  SUPABASE_JWT_SECRET=your-jwt-secret
6
 
7
- # Stripe (v22 β€” no apiVersion needed)
8
- STRIPE_SECRET_KEY=sk_live_...
9
- STRIPE_WEBHOOK_SECRET=whsec_...
10
- STRIPE_PRO_PRICE_ID=price_...
11
- STRIPE_TEAM_PRICE_ID=price_...
 
 
12
 
13
  # Resend
14
  RESEND_API_KEY=re_...
15
 
16
  # App
17
- NEXT_PUBLIC_SITE_URL=https://clauseguard.com
18
- CLAUSEGUARD_API_URL=http://localhost:8000
19
-
20
- # ML (optional β€” for SaulLM explain feature)
21
- HF_API_TOKEN=hf_...
22
- SAULLM_ENDPOINT=https://your-endpoint.endpoints.huggingface.cloud
 
4
  SUPABASE_SERVICE_ROLE_KEY=eyJ...
5
  SUPABASE_JWT_SECRET=your-jwt-secret
6
 
7
+ # Razorpay
8
+ RAZORPAY_KEY_ID=rzp_test_...
9
+ RAZORPAY_KEY_SECRET=...
10
+ RAZORPAY_WEBHOOK_SECRET=...
11
+ NEXT_PUBLIC_RAZORPAY_KEY_ID=rzp_test_...
12
+ RAZORPAY_PRO_PLAN_ID=plan_...
13
+ RAZORPAY_TEAM_PLAN_ID=plan_...
14
 
15
  # Resend
16
  RESEND_API_KEY=re_...
17
 
18
  # App
19
+ NEXT_PUBLIC_SITE_URL=http://localhost:3000
20
+ CLAUSEGUARD_API_URL=https://gaurv007-clauseguard-api.hf.space
 
 
 
 
web/app/api/stripe/checkout/route.ts DELETED
@@ -1,59 +0,0 @@
1
- import { NextRequest, NextResponse } from "next/server";
2
- import { getStripe, PLANS } from "@/lib/stripe";
3
- import { createClient } from "@/lib/supabase/server";
4
-
5
- export async function POST(req: NextRequest) {
6
- try {
7
- const supabase = await createClient();
8
- const { data: { user } } = await supabase.auth.getUser();
9
-
10
- if (!user) {
11
- return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
12
- }
13
-
14
- const { plan } = await req.json();
15
-
16
- if (plan !== "pro" && plan !== "team") {
17
- return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
18
- }
19
-
20
- const priceId = PLANS[plan].price_id;
21
- if (!priceId) {
22
- return NextResponse.json({ error: "Price not configured" }, { status: 500 });
23
- }
24
-
25
- const stripe = getStripe();
26
-
27
- const { data: profile } = await supabase
28
- .from("profiles")
29
- .select("stripe_customer_id")
30
- .eq("id", user.id)
31
- .single();
32
-
33
- let customerId = profile?.stripe_customer_id;
34
-
35
- if (!customerId) {
36
- const customer = await stripe.customers.create({
37
- email: user.email,
38
- metadata: { supabase_user_id: user.id },
39
- });
40
- customerId = customer.id;
41
- await supabase.from("profiles").update({ stripe_customer_id: customerId }).eq("id", user.id);
42
- }
43
-
44
- const session = await stripe.checkout.sessions.create({
45
- customer: customerId,
46
- mode: "subscription",
47
- payment_method_types: ["card"],
48
- line_items: [{ price: priceId, quantity: 1 }],
49
- success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard-pages/dashboard?session_id={CHECKOUT_SESSION_ID}`,
50
- cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing`,
51
- subscription_data: { metadata: { supabase_user_id: user.id, plan } },
52
- });
53
-
54
- return NextResponse.json({ url: session.url });
55
- } catch (error) {
56
- console.error("Checkout error:", error);
57
- return NextResponse.json({ error: "Checkout failed" }, { status: 500 });
58
- }
59
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/app/api/stripe/webhook/route.ts DELETED
@@ -1,81 +0,0 @@
1
- import { NextRequest, NextResponse } from "next/server";
2
- import { getStripe } from "@/lib/stripe";
3
- import { createClient } from "@supabase/supabase-js";
4
- import { Resend } from "resend";
5
-
6
- const supabase = createClient(
7
- process.env.NEXT_PUBLIC_SUPABASE_URL!,
8
- process.env.SUPABASE_SERVICE_ROLE_KEY!
9
- );
10
-
11
- const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null;
12
-
13
- export async function POST(req: NextRequest) {
14
- const body = await req.text();
15
- const sig = req.headers.get("stripe-signature")!;
16
- const stripe = getStripe();
17
-
18
- let event;
19
- try {
20
- event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
21
- } catch {
22
- return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
23
- }
24
-
25
- switch (event.type) {
26
- case "customer.subscription.created":
27
- case "customer.subscription.updated": {
28
- const sub = event.data.object as any;
29
- const plan = sub.metadata?.plan || "pro";
30
-
31
- if (sub.status === "active" || sub.status === "trialing") {
32
- const { data } = await supabase
33
- .from("profiles")
34
- .update({ plan, stripe_subscription_id: sub.id, updated_at: new Date().toISOString() })
35
- .eq("stripe_customer_id", sub.customer)
36
- .select("email")
37
- .single();
38
-
39
- if (data?.email && resend && event.type === "customer.subscription.created") {
40
- await resend.emails.send({
41
- from: "ClauseGuard <noreply@clauseguard.com>",
42
- to: [data.email],
43
- subject: `Welcome to ClauseGuard ${plan.charAt(0).toUpperCase() + plan.slice(1)}`,
44
- html: `<p>Your ${plan} subscription is active. You now have unlimited scans.</p><p><a href="https://clauseguard.com/dashboard-pages/dashboard">Go to dashboard</a></p>`,
45
- });
46
- }
47
- }
48
- break;
49
- }
50
-
51
- case "customer.subscription.deleted": {
52
- const sub = event.data.object as any;
53
- await supabase
54
- .from("profiles")
55
- .update({ plan: "free", stripe_subscription_id: null, updated_at: new Date().toISOString() })
56
- .eq("stripe_customer_id", sub.customer);
57
- break;
58
- }
59
-
60
- case "invoice.payment_failed": {
61
- const invoice = event.data.object as any;
62
- const { data } = await supabase
63
- .from("profiles")
64
- .select("email")
65
- .eq("stripe_customer_id", invoice.customer)
66
- .single();
67
-
68
- if (data?.email && resend) {
69
- await resend.emails.send({
70
- from: "ClauseGuard <noreply@clauseguard.com>",
71
- to: [data.email],
72
- subject: "Payment failed β€” action needed",
73
- html: "<p>Your latest payment failed. Please update your payment method to keep your subscription active.</p>",
74
- });
75
- }
76
- break;
77
- }
78
- }
79
-
80
- return NextResponse.json({ received: true });
81
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/app/api/subscribe/cancel/route.ts ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getRazorpay } from "@/lib/razorpay";
3
+ import { createClient } from "@/lib/supabase/server";
4
+
5
+ export async function POST(req: NextRequest) {
6
+ try {
7
+ const supabase = await createClient();
8
+ const { data: { user } } = await supabase.auth.getUser();
9
+ if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
10
+
11
+ const { data: profile } = await supabase
12
+ .from("profiles")
13
+ .select("razorpay_subscription_id")
14
+ .eq("id", user.id)
15
+ .single();
16
+
17
+ if (!profile?.razorpay_subscription_id) {
18
+ return NextResponse.json({ error: "No active subscription" }, { status: 400 });
19
+ }
20
+
21
+ const razorpay = getRazorpay();
22
+
23
+ // Cancel at end of billing cycle (like Stripe's cancel_at_period_end)
24
+ await razorpay.subscriptions.cancel(profile.razorpay_subscription_id, true);
25
+
26
+ await supabase
27
+ .from("profiles")
28
+ .update({ updated_at: new Date().toISOString() })
29
+ .eq("id", user.id);
30
+
31
+ return NextResponse.json({ success: true, message: "Subscription will cancel at end of current period." });
32
+ } catch (error: any) {
33
+ console.error("Cancel error:", error);
34
+ return NextResponse.json({ error: error.message || "Cancel failed" }, { status: 500 });
35
+ }
36
+ }
web/app/api/subscribe/create/route.ts ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getRazorpay, PLANS } from "@/lib/razorpay";
3
+ import { createClient } from "@/lib/supabase/server";
4
+
5
+ export async function POST(req: NextRequest) {
6
+ try {
7
+ const supabase = await createClient();
8
+ const { data: { user } } = await supabase.auth.getUser();
9
+ if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
10
+
11
+ const { plan } = await req.json();
12
+ if (plan !== "pro" && plan !== "team") {
13
+ return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
14
+ }
15
+
16
+ const planId = PLANS[plan].razorpay_plan_id;
17
+ if (!planId) return NextResponse.json({ error: "Plan not configured" }, { status: 500 });
18
+
19
+ const razorpay = getRazorpay();
20
+
21
+ const subscription = await razorpay.subscriptions.create({
22
+ plan_id: planId,
23
+ total_count: 120,
24
+ quantity: 1,
25
+ customer_notify: 1,
26
+ notes: {
27
+ user_id: user.id,
28
+ email: user.email || "",
29
+ plan: plan,
30
+ },
31
+ });
32
+
33
+ // Save subscription ID to profile
34
+ await supabase
35
+ .from("profiles")
36
+ .update({
37
+ razorpay_subscription_id: subscription.id,
38
+ updated_at: new Date().toISOString(),
39
+ })
40
+ .eq("id", user.id);
41
+
42
+ return NextResponse.json({
43
+ subscription_id: subscription.id,
44
+ short_url: subscription.short_url,
45
+ });
46
+ } catch (error: any) {
47
+ console.error("Subscription create error:", error);
48
+ return NextResponse.json({ error: error.message || "Failed" }, { status: 500 });
49
+ }
50
+ }
web/app/api/subscribe/verify/route.ts ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { validatePaymentVerification } from "razorpay/dist/utils/razorpay-utils";
3
+ import { createClient } from "@/lib/supabase/server";
4
+
5
+ export async function POST(req: NextRequest) {
6
+ try {
7
+ const { razorpay_payment_id, razorpay_subscription_id, razorpay_signature } = await req.json();
8
+
9
+ const isValid = validatePaymentVerification(
10
+ { payment_id: razorpay_payment_id, subscription_id: razorpay_subscription_id },
11
+ razorpay_signature,
12
+ process.env.RAZORPAY_KEY_SECRET!
13
+ );
14
+
15
+ if (!isValid) {
16
+ return NextResponse.json({ error: "Invalid payment signature" }, { status: 400 });
17
+ }
18
+
19
+ // Activate the plan
20
+ const supabase = await createClient();
21
+ const { data: { user } } = await supabase.auth.getUser();
22
+
23
+ if (user) {
24
+ // Get plan from profile's stored subscription
25
+ const { data: profile } = await supabase
26
+ .from("profiles")
27
+ .select("razorpay_subscription_id")
28
+ .eq("id", user.id)
29
+ .single();
30
+
31
+ // Fetch subscription to get plan details from notes
32
+ const { getRazorpay } = await import("@/lib/razorpay");
33
+ const rp = getRazorpay();
34
+ const sub = await rp.subscriptions.fetch(razorpay_subscription_id);
35
+ const plan = (sub.notes as any)?.plan || "pro";
36
+
37
+ await supabase
38
+ .from("profiles")
39
+ .update({
40
+ plan,
41
+ razorpay_subscription_id,
42
+ updated_at: new Date().toISOString(),
43
+ })
44
+ .eq("id", user.id);
45
+ }
46
+
47
+ return NextResponse.json({ success: true });
48
+ } catch (error: any) {
49
+ console.error("Verification error:", error);
50
+ return NextResponse.json({ error: error.message || "Verification failed" }, { status: 500 });
51
+ }
52
+ }
web/app/api/webhooks/razorpay/route.ts ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import Razorpay from "razorpay";
3
+ import { createClient } from "@supabase/supabase-js";
4
+
5
+ const supabase = createClient(
6
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
7
+ process.env.SUPABASE_SERVICE_ROLE_KEY!
8
+ );
9
+
10
+ let resend: any = null;
11
+ if (process.env.RESEND_API_KEY) {
12
+ import("resend").then(({ Resend }) => { resend = new Resend(process.env.RESEND_API_KEY); });
13
+ }
14
+
15
+ export async function POST(req: NextRequest) {
16
+ const rawBody = await req.text();
17
+ const signature = req.headers.get("x-razorpay-signature") || "";
18
+ const webhookSecret = process.env.RAZORPAY_WEBHOOK_SECRET!;
19
+
20
+ // Verify signature
21
+ const isValid = Razorpay.validateWebhookSignature(rawBody, signature, webhookSecret);
22
+ if (!isValid) {
23
+ return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
24
+ }
25
+
26
+ const event = JSON.parse(rawBody);
27
+ const eventType: string = event.event;
28
+
29
+ switch (eventType) {
30
+ case "subscription.activated": {
31
+ const sub = event.payload.subscription.entity;
32
+ const plan = sub.notes?.plan || "pro";
33
+ const userId = sub.notes?.user_id;
34
+
35
+ if (userId) {
36
+ const { data } = await supabase
37
+ .from("profiles")
38
+ .update({
39
+ plan,
40
+ razorpay_subscription_id: sub.id,
41
+ updated_at: new Date().toISOString(),
42
+ })
43
+ .eq("id", userId)
44
+ .select("email")
45
+ .single();
46
+
47
+ // Send welcome email
48
+ if (data?.email && resend) {
49
+ await resend.emails.send({
50
+ from: "ClauseGuard <noreply@clauseguard.com>",
51
+ to: [data.email],
52
+ subject: `Welcome to ClauseGuard ${plan.charAt(0).toUpperCase() + plan.slice(1)}`,
53
+ html: `<p>Your ${plan} subscription is active. You now have unlimited scans.</p><p><a href="https://clauseguard.com/dashboard-pages/dashboard">Go to dashboard</a></p>`,
54
+ });
55
+ }
56
+ }
57
+ break;
58
+ }
59
+
60
+ case "subscription.charged": {
61
+ // Recurring payment succeeded β€” nothing to update, plan already active
62
+ break;
63
+ }
64
+
65
+ case "subscription.cancelled":
66
+ case "subscription.completed": {
67
+ const sub = event.payload.subscription.entity;
68
+ const userId = sub.notes?.user_id;
69
+
70
+ if (userId) {
71
+ await supabase
72
+ .from("profiles")
73
+ .update({
74
+ plan: "free",
75
+ razorpay_subscription_id: null,
76
+ updated_at: new Date().toISOString(),
77
+ })
78
+ .eq("id", userId);
79
+ }
80
+ break;
81
+ }
82
+
83
+ case "subscription.halted": {
84
+ const sub = event.payload.subscription.entity;
85
+ const userId = sub.notes?.user_id;
86
+
87
+ if (userId) {
88
+ const { data } = await supabase
89
+ .from("profiles")
90
+ .select("email")
91
+ .eq("id", userId)
92
+ .single();
93
+
94
+ if (data?.email && resend) {
95
+ await resend.emails.send({
96
+ from: "ClauseGuard <noreply@clauseguard.com>",
97
+ to: [data.email],
98
+ subject: "Payment failed β€” subscription paused",
99
+ html: "<p>Your payment failed and your subscription has been paused. Please update your payment method to continue.</p>",
100
+ });
101
+ }
102
+ }
103
+ break;
104
+ }
105
+
106
+ case "payment.failed": {
107
+ // Razorpay auto-retries for subscriptions β€” log for monitoring
108
+ const payment = event.payload.payment.entity;
109
+ console.warn("Payment failed:", payment.id, payment.error_description);
110
+ break;
111
+ }
112
+ }
113
+
114
+ return NextResponse.json({ status: "ok" });
115
+ }
web/app/dashboard-pages/settings/page.tsx CHANGED
@@ -1,23 +1,23 @@
1
  import { createClient } from "@/lib/supabase/server";
2
- import { getStripe } from "@/lib/stripe";
3
  import { redirect } from "next/navigation";
4
  import Link from "next/link";
 
5
 
6
- async function createBillingPortal(formData: FormData) {
7
  "use server";
8
  const supabase = await createClient();
9
  const { data: { user } } = await supabase.auth.getUser();
10
  if (!user) redirect("/auth/login");
11
 
12
- const { data: profile } = await supabase.from("profiles").select("stripe_customer_id").eq("id", user.id).single();
13
- if (!profile?.stripe_customer_id) redirect("/pricing");
14
 
15
- const stripe = getStripe();
16
- const { url } = await stripe.billingPortal.sessions.create({
17
- customer: profile.stripe_customer_id,
18
- return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard-pages/settings`,
19
- });
20
- redirect(url!);
21
  }
22
 
23
  async function handleSignOut() {
@@ -39,76 +39,70 @@ export default async function SettingsPage() {
39
 
40
  const plan = profile?.plan || "free";
41
  const used = profile?.analyses_this_month || 0;
42
- const limit = plan === "free" ? 10 : "Unlimited";
 
43
 
44
  return (
45
  <div className="min-h-screen bg-white">
46
- <div className="max-w-2xl mx-auto px-6 py-12">
47
  <div className="mb-8">
48
- <Link href="/dashboard-pages/dashboard" className="text-sm text-zinc-400 hover:text-zinc-600">← Dashboard</Link>
49
- <h1 className="mt-4 text-2xl font-semibold">Settings</h1>
 
 
50
  </div>
51
 
52
  {/* Account */}
53
- <section className="mb-10">
54
- <h2 className="text-sm font-medium text-zinc-500 uppercase tracking-wide mb-4">Account</h2>
55
- <div className="border border-zinc-200 rounded-lg divide-y divide-zinc-100">
56
- <div className="px-5 py-4 flex justify-between items-center">
57
- <div>
58
- <p className="text-sm font-medium">Email</p>
59
- <p className="text-sm text-zinc-500">{user?.email}</p>
60
- </div>
61
- </div>
62
  <div className="px-5 py-4 flex justify-between items-center">
63
- <div>
64
- <p className="text-sm font-medium">Name</p>
65
- <p className="text-sm text-zinc-500">{profile?.full_name || "Not set"}</p>
66
- </div>
67
  </div>
68
  <div className="px-5 py-4 flex justify-between items-center">
69
- <div>
70
- <p className="text-sm font-medium">User ID</p>
71
- <p className="text-sm text-zinc-400 font-mono text-xs">{user?.id}</p>
72
- </div>
73
  </div>
74
  </div>
75
  </section>
76
 
77
  {/* Subscription */}
78
- <section className="mb-10">
79
- <h2 className="text-sm font-medium text-zinc-500 uppercase tracking-wide mb-4">Subscription</h2>
80
- <div className="border border-zinc-200 rounded-lg divide-y divide-zinc-100">
 
 
 
81
  <div className="px-5 py-4 flex justify-between items-center">
82
- <div>
83
- <p className="text-sm font-medium">Plan</p>
84
- <p className="text-sm text-zinc-500 capitalize">{plan}</p>
85
- </div>
86
  {plan === "free" ? (
87
- <Link href="/#pricing" className="text-sm text-zinc-900 font-medium border border-zinc-200 px-3 py-1.5 rounded-md hover:bg-zinc-50">
88
- Upgrade
89
- </Link>
90
- ) : (
91
- <form action={createBillingPortal}>
92
- <button type="submit" className="text-sm text-zinc-900 font-medium border border-zinc-200 px-3 py-1.5 rounded-md hover:bg-zinc-50">
93
- Manage billing
94
- </button>
95
- </form>
96
- )}
97
  </div>
98
  <div className="px-5 py-4 flex justify-between items-center">
99
- <div>
100
- <p className="text-sm font-medium">Usage this month</p>
101
- <p className="text-sm text-zinc-500">{used} / {limit} scans</p>
102
- </div>
103
  </div>
 
 
 
 
 
 
 
 
 
 
 
104
  </div>
105
  </section>
106
 
107
- {/* Danger zone */}
108
  <section>
109
- <h2 className="text-sm font-medium text-zinc-500 uppercase tracking-wide mb-4">Session</h2>
110
  <form action={handleSignOut}>
111
- <button type="submit" className="text-sm text-red-600 font-medium border border-red-200 px-4 py-2 rounded-md hover:bg-red-50">
 
112
  Sign out
113
  </button>
114
  </form>
 
1
  import { createClient } from "@/lib/supabase/server";
 
2
  import { redirect } from "next/navigation";
3
  import Link from "next/link";
4
+ import { ArrowLeft, User, CreditCard, LogOut, CircleAlert } from "lucide-react";
5
 
6
+ async function handleCancel() {
7
  "use server";
8
  const supabase = await createClient();
9
  const { data: { user } } = await supabase.auth.getUser();
10
  if (!user) redirect("/auth/login");
11
 
12
+ const { data: profile } = await supabase.from("profiles").select("razorpay_subscription_id").eq("id", user.id).single();
13
+ if (!profile?.razorpay_subscription_id) return;
14
 
15
+ const { getRazorpay } = await import("@/lib/razorpay");
16
+ const rp = getRazorpay();
17
+ await rp.subscriptions.cancel(profile.razorpay_subscription_id, true);
18
+
19
+ await supabase.from("profiles").update({ updated_at: new Date().toISOString() }).eq("id", user.id);
20
+ redirect("/dashboard-pages/settings?cancelled=true");
21
  }
22
 
23
  async function handleSignOut() {
 
39
 
40
  const plan = profile?.plan || "free";
41
  const used = profile?.analyses_this_month || 0;
42
+ const limit = plan === "free" ? "10" : "Unlimited";
43
+ const hasSub = !!profile?.razorpay_subscription_id;
44
 
45
  return (
46
  <div className="min-h-screen bg-white">
47
+ <div className="max-w-2xl mx-auto px-5 py-12">
48
  <div className="mb-8">
49
+ <Link href="/dashboard-pages/dashboard" className="inline-flex items-center gap-1.5 text-sm text-zinc-400 hover:text-zinc-600">
50
+ <ArrowLeft className="w-3.5 h-3.5" /> Dashboard
51
+ </Link>
52
+ <h1 className="mt-4 text-2xl font-semibold tracking-tight">Settings</h1>
53
  </div>
54
 
55
  {/* Account */}
56
+ <section className="mb-8">
57
+ <div className="flex items-center gap-2 mb-3">
58
+ <User className="w-4 h-4 text-zinc-400" />
59
+ <h2 className="text-sm font-medium text-zinc-500 uppercase tracking-wider">Account</h2>
60
+ </div>
61
+ <div className="border border-zinc-200 rounded-xl divide-y divide-zinc-100">
 
 
 
62
  <div className="px-5 py-4 flex justify-between items-center">
63
+ <div><p className="text-sm font-medium">Email</p><p className="text-sm text-zinc-500">{user?.email}</p></div>
 
 
 
64
  </div>
65
  <div className="px-5 py-4 flex justify-between items-center">
66
+ <div><p className="text-sm font-medium">Name</p><p className="text-sm text-zinc-500">{profile?.full_name || "Not set"}</p></div>
 
 
 
67
  </div>
68
  </div>
69
  </section>
70
 
71
  {/* Subscription */}
72
+ <section className="mb-8">
73
+ <div className="flex items-center gap-2 mb-3">
74
+ <CreditCard className="w-4 h-4 text-zinc-400" />
75
+ <h2 className="text-sm font-medium text-zinc-500 uppercase tracking-wider">Subscription</h2>
76
+ </div>
77
+ <div className="border border-zinc-200 rounded-xl divide-y divide-zinc-100">
78
  <div className="px-5 py-4 flex justify-between items-center">
79
+ <div><p className="text-sm font-medium">Plan</p><p className="text-sm text-zinc-500 capitalize">{plan}</p></div>
 
 
 
80
  {plan === "free" ? (
81
+ <Link href="/#pricing" className="text-sm font-medium border border-zinc-200 px-3 py-1.5 rounded-lg hover:bg-zinc-50 transition-colors">Upgrade</Link>
82
+ ) : null}
 
 
 
 
 
 
 
 
83
  </div>
84
  <div className="px-5 py-4 flex justify-between items-center">
85
+ <div><p className="text-sm font-medium">Usage this month</p><p className="text-sm text-zinc-500">{used} / {limit} scans</p></div>
 
 
 
86
  </div>
87
+ {hasSub && plan !== "free" && (
88
+ <div className="px-5 py-4">
89
+ <form action={handleCancel}>
90
+ <button type="submit" className="flex items-center gap-2 text-sm text-red-600 font-medium border border-red-200 px-3 py-1.5 rounded-lg hover:bg-red-50 transition-colors">
91
+ <CircleAlert className="w-3.5 h-3.5" />
92
+ Cancel subscription
93
+ </button>
94
+ </form>
95
+ <p className="mt-1.5 text-xs text-zinc-400">Access continues until end of current billing period.</p>
96
+ </div>
97
+ )}
98
  </div>
99
  </section>
100
 
101
+ {/* Sign out */}
102
  <section>
 
103
  <form action={handleSignOut}>
104
+ <button type="submit" className="flex items-center gap-2 text-sm text-zinc-500 font-medium border border-zinc-200 px-4 py-2 rounded-lg hover:bg-zinc-50 transition-colors">
105
+ <LogOut className="w-4 h-4" />
106
  Sign out
107
  </button>
108
  </form>
web/components/checkout-button.tsx ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { CreditCard, Loader2 } from "lucide-react";
5
+
6
+ // Razorpay checkout script loader
7
+ function loadRazorpayScript(): Promise<boolean> {
8
+ return new Promise((resolve) => {
9
+ if (typeof window !== "undefined" && (window as any).Razorpay) {
10
+ resolve(true);
11
+ return;
12
+ }
13
+ const script = document.createElement("script");
14
+ script.src = "https://checkout.razorpay.com/v1/checkout.js";
15
+ script.onload = () => resolve(true);
16
+ script.onerror = () => resolve(false);
17
+ document.body.appendChild(script);
18
+ });
19
+ }
20
+
21
+ interface Props {
22
+ plan: "pro" | "team";
23
+ label?: string;
24
+ className?: string;
25
+ userName?: string;
26
+ userEmail?: string;
27
+ }
28
+
29
+ export function CheckoutButton({ plan, label, className, userName, userEmail }: Props) {
30
+ const [loading, setLoading] = useState(false);
31
+
32
+ async function handleCheckout() {
33
+ setLoading(true);
34
+
35
+ try {
36
+ // Load Razorpay script
37
+ const loaded = await loadRazorpayScript();
38
+ if (!loaded) throw new Error("Failed to load Razorpay");
39
+
40
+ // Create subscription server-side
41
+ const res = await fetch("/api/subscribe/create", {
42
+ method: "POST",
43
+ headers: { "Content-Type": "application/json" },
44
+ body: JSON.stringify({ plan }),
45
+ });
46
+
47
+ if (!res.ok) {
48
+ const err = await res.json();
49
+ throw new Error(err.error || "Subscription failed");
50
+ }
51
+
52
+ const { subscription_id } = await res.json();
53
+
54
+ // Open Razorpay checkout
55
+ const rzp = new (window as any).Razorpay({
56
+ key: process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID,
57
+ subscription_id,
58
+ name: "ClauseGuard",
59
+ description: plan === "pro" ? "Pro β€” β‚Ή999/mo" : "Team β€” β‚Ή3,999/mo",
60
+ handler: async (response: any) => {
61
+ // Verify payment server-side
62
+ const verifyRes = await fetch("/api/subscribe/verify", {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify(response),
66
+ });
67
+
68
+ if (verifyRes.ok) {
69
+ window.location.href = "/dashboard-pages/dashboard?subscribed=true";
70
+ }
71
+ },
72
+ prefill: {
73
+ name: userName || "",
74
+ email: userEmail || "",
75
+ },
76
+ theme: { color: "#18181b" },
77
+ modal: {
78
+ ondismiss: () => setLoading(false),
79
+ },
80
+ });
81
+
82
+ rzp.on("payment.failed", () => setLoading(false));
83
+ rzp.open();
84
+ } catch (error) {
85
+ console.error("Checkout error:", error);
86
+ setLoading(false);
87
+ }
88
+ }
89
+
90
+ return (
91
+ <button
92
+ onClick={handleCheckout}
93
+ disabled={loading}
94
+ className={className || "w-full flex items-center justify-center gap-2 bg-zinc-900 text-white py-2.5 rounded-lg text-sm font-medium hover:bg-zinc-800 disabled:opacity-40 transition-colors"}
95
+ >
96
+ {loading ? (
97
+ <><Loader2 className="w-4 h-4 animate-spin" /> Processing...</>
98
+ ) : (
99
+ <><CreditCard className="w-4 h-4" /> {label || `Subscribe to ${plan}`}</>
100
+ )}
101
+ </button>
102
+ );
103
+ }
web/lib/{stripe.ts β†’ razorpay.ts} RENAMED
@@ -1,31 +1,37 @@
1
- import Stripe from "stripe";
2
 
3
- let _stripe: Stripe | null = null;
4
 
5
- export function getStripe(): Stripe {
6
- if (!_stripe) {
7
- _stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
 
 
 
8
  }
9
- return _stripe;
10
  }
11
 
12
  export const PLANS = {
13
  free: {
14
  name: "Free",
15
  scans: 10,
16
- price_id: null,
 
17
  features: ["10 scans per month", "All 8 clause categories", "Risk score and grade"],
18
  },
19
  pro: {
20
  name: "Pro",
21
  scans: Infinity,
22
- price_id: process.env.STRIPE_PRO_PRICE_ID!,
 
23
  features: ["Unlimited scans", "Contract uploads", "Clause explanations", "PDF exports"],
24
  },
25
  team: {
26
  name: "Team",
27
  scans: Infinity,
28
- price_id: process.env.STRIPE_TEAM_PRICE_ID!,
 
29
  features: ["Everything in Pro", "5 team seats", "10K API calls", "Priority support"],
30
  },
31
  } as const;
 
1
+ import Razorpay from "razorpay";
2
 
3
+ let _razorpay: Razorpay | null = null;
4
 
5
+ export function getRazorpay(): Razorpay {
6
+ if (!_razorpay) {
7
+ _razorpay = new Razorpay({
8
+ key_id: process.env.RAZORPAY_KEY_ID!,
9
+ key_secret: process.env.RAZORPAY_KEY_SECRET!,
10
+ });
11
  }
12
+ return _razorpay;
13
  }
14
 
15
  export const PLANS = {
16
  free: {
17
  name: "Free",
18
  scans: 10,
19
+ razorpay_plan_id: null,
20
+ price_label: "β‚Ή0",
21
  features: ["10 scans per month", "All 8 clause categories", "Risk score and grade"],
22
  },
23
  pro: {
24
  name: "Pro",
25
  scans: Infinity,
26
+ razorpay_plan_id: process.env.RAZORPAY_PRO_PLAN_ID!,
27
+ price_label: "β‚Ή999/mo",
28
  features: ["Unlimited scans", "Contract uploads", "Clause explanations", "PDF exports"],
29
  },
30
  team: {
31
  name: "Team",
32
  scans: Infinity,
33
+ razorpay_plan_id: process.env.RAZORPAY_TEAM_PLAN_ID!,
34
+ price_label: "β‚Ή3,999/mo",
35
  features: ["Everything in Pro", "5 team seats", "10K API calls", "Priority support"],
36
  },
37
  } as const;
web/lib/supabase/schema.sql CHANGED
@@ -1,14 +1,12 @@
1
- -- ClauseGuard β€” Supabase Database Schema
2
- -- Run this in Supabase SQL Editor to set up the database
3
 
4
- -- ─── Profiles (extends auth.users) ───
5
  CREATE TABLE IF NOT EXISTS public.profiles (
6
  id UUID REFERENCES auth.users ON DELETE CASCADE PRIMARY KEY,
7
  email TEXT,
8
  full_name TEXT,
9
  avatar_url TEXT,
10
- stripe_customer_id TEXT UNIQUE,
11
- stripe_subscription_id TEXT,
12
  plan TEXT DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'team')),
13
  analyses_this_month INT DEFAULT 0,
14
  monthly_reset_at TIMESTAMPTZ DEFAULT date_trunc('month', NOW()),
@@ -16,7 +14,7 @@ CREATE TABLE IF NOT EXISTS public.profiles (
16
  updated_at TIMESTAMPTZ DEFAULT NOW()
17
  );
18
 
19
- -- ─── Analyses (scan history) ───
20
  CREATE TABLE IF NOT EXISTS public.analyses (
21
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
22
  user_id UUID REFERENCES public.profiles ON DELETE CASCADE NOT NULL,
@@ -30,45 +28,27 @@ CREATE TABLE IF NOT EXISTS public.analyses (
30
  created_at TIMESTAMPTZ DEFAULT NOW()
31
  );
32
 
33
- -- ─── Indexes ───
34
  CREATE INDEX IF NOT EXISTS idx_analyses_user_id ON public.analyses(user_id);
35
  CREATE INDEX IF NOT EXISTS idx_analyses_created_at ON public.analyses(created_at DESC);
36
- CREATE INDEX IF NOT EXISTS idx_profiles_stripe_customer ON public.profiles(stripe_customer_id);
37
 
38
- -- ─── Row Level Security ───
39
  ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
40
  ALTER TABLE public.analyses ENABLE ROW LEVEL SECURITY;
41
 
42
- -- Profiles: users can only see/edit their own profile
43
- CREATE POLICY "Users can view own profile"
44
- ON public.profiles FOR SELECT
45
- USING (auth.uid() = id);
 
46
 
47
- CREATE POLICY "Users can update own profile"
48
- ON public.profiles FOR UPDATE
49
- USING (auth.uid() = id);
50
-
51
- -- Analyses: users can only see their own scans
52
- CREATE POLICY "Users can view own analyses"
53
- ON public.analyses FOR SELECT
54
- USING (auth.uid() = user_id);
55
-
56
- CREATE POLICY "Users can insert own analyses"
57
- ON public.analyses FOR INSERT
58
- WITH CHECK (auth.uid() = user_id);
59
-
60
- CREATE POLICY "Users can delete own analyses"
61
- ON public.analyses FOR DELETE
62
- USING (auth.uid() = user_id);
63
-
64
- -- ─── Auto-create profile on signup ───
65
  CREATE OR REPLACE FUNCTION public.handle_new_user()
66
  RETURNS TRIGGER AS $$
67
  BEGIN
68
  INSERT INTO public.profiles (id, email, full_name, avatar_url)
69
  VALUES (
70
- NEW.id,
71
- NEW.email,
72
  COALESCE(NEW.raw_user_meta_data ->> 'full_name', NEW.raw_user_meta_data ->> 'name', ''),
73
  COALESCE(NEW.raw_user_meta_data ->> 'avatar_url', NEW.raw_user_meta_data ->> 'picture', '')
74
  );
@@ -80,13 +60,12 @@ CREATE OR REPLACE TRIGGER on_auth_user_created
80
  AFTER INSERT ON auth.users
81
  FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
82
 
83
- -- ─── Monthly usage reset function ───
84
  CREATE OR REPLACE FUNCTION public.reset_monthly_usage()
85
  RETURNS void AS $$
86
  BEGIN
87
  UPDATE public.profiles
88
- SET analyses_this_month = 0,
89
- monthly_reset_at = date_trunc('month', NOW())
90
  WHERE monthly_reset_at < date_trunc('month', NOW());
91
  END;
92
  $$ LANGUAGE plpgsql SECURITY DEFINER;
 
1
+ -- ClauseGuard β€” Supabase Database Schema (Razorpay)
 
2
 
3
+ -- Profiles
4
  CREATE TABLE IF NOT EXISTS public.profiles (
5
  id UUID REFERENCES auth.users ON DELETE CASCADE PRIMARY KEY,
6
  email TEXT,
7
  full_name TEXT,
8
  avatar_url TEXT,
9
+ razorpay_subscription_id TEXT,
 
10
  plan TEXT DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'team')),
11
  analyses_this_month INT DEFAULT 0,
12
  monthly_reset_at TIMESTAMPTZ DEFAULT date_trunc('month', NOW()),
 
14
  updated_at TIMESTAMPTZ DEFAULT NOW()
15
  );
16
 
17
+ -- Analyses
18
  CREATE TABLE IF NOT EXISTS public.analyses (
19
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
20
  user_id UUID REFERENCES public.profiles ON DELETE CASCADE NOT NULL,
 
28
  created_at TIMESTAMPTZ DEFAULT NOW()
29
  );
30
 
31
+ -- Indexes
32
  CREATE INDEX IF NOT EXISTS idx_analyses_user_id ON public.analyses(user_id);
33
  CREATE INDEX IF NOT EXISTS idx_analyses_created_at ON public.analyses(created_at DESC);
 
34
 
35
+ -- Row Level Security
36
  ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
37
  ALTER TABLE public.analyses ENABLE ROW LEVEL SECURITY;
38
 
39
+ CREATE POLICY "Users can view own profile" ON public.profiles FOR SELECT USING (auth.uid() = id);
40
+ CREATE POLICY "Users can update own profile" ON public.profiles FOR UPDATE USING (auth.uid() = id);
41
+ CREATE POLICY "Users can view own analyses" ON public.analyses FOR SELECT USING (auth.uid() = user_id);
42
+ CREATE POLICY "Users can insert own analyses" ON public.analyses FOR INSERT WITH CHECK (auth.uid() = user_id);
43
+ CREATE POLICY "Users can delete own analyses" ON public.analyses FOR DELETE USING (auth.uid() = user_id);
44
 
45
+ -- Auto-create profile on signup
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  CREATE OR REPLACE FUNCTION public.handle_new_user()
47
  RETURNS TRIGGER AS $$
48
  BEGIN
49
  INSERT INTO public.profiles (id, email, full_name, avatar_url)
50
  VALUES (
51
+ NEW.id, NEW.email,
 
52
  COALESCE(NEW.raw_user_meta_data ->> 'full_name', NEW.raw_user_meta_data ->> 'name', ''),
53
  COALESCE(NEW.raw_user_meta_data ->> 'avatar_url', NEW.raw_user_meta_data ->> 'picture', '')
54
  );
 
60
  AFTER INSERT ON auth.users
61
  FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
62
 
63
+ -- Monthly usage reset
64
  CREATE OR REPLACE FUNCTION public.reset_monthly_usage()
65
  RETURNS void AS $$
66
  BEGIN
67
  UPDATE public.profiles
68
+ SET analyses_this_month = 0, monthly_reset_at = date_trunc('month', NOW())
 
69
  WHERE monthly_reset_at < date_trunc('month', NOW());
70
  END;
71
  $$ LANGUAGE plpgsql SECURITY DEFINER;
web/package.json CHANGED
@@ -14,7 +14,7 @@
14
  "react-dom": "19.2.5",
15
  "@supabase/supabase-js": "2.104.0",
16
  "@supabase/ssr": "0.10.2",
17
- "stripe": "22.0.2",
18
  "resend": "6.12.2",
19
  "@react-pdf/renderer": "4.5.1",
20
  "jose": "6.2.2",
 
14
  "react-dom": "19.2.5",
15
  "@supabase/supabase-js": "2.104.0",
16
  "@supabase/ssr": "0.10.2",
17
+ "razorpay": "2.9.6",
18
  "resend": "6.12.2",
19
  "@react-pdf/renderer": "4.5.1",
20
  "jose": "6.2.2",