Spaces:
Sleeping
Sleeping
Replace Stripe with Razorpay: subscriptions, webhooks, checkout modal, INR pricing
Browse files- web/.env.example +9 -11
- web/app/api/stripe/checkout/route.ts +0 -59
- web/app/api/stripe/webhook/route.ts +0 -81
- web/app/api/subscribe/cancel/route.ts +36 -0
- web/app/api/subscribe/create/route.ts +50 -0
- web/app/api/subscribe/verify/route.ts +52 -0
- web/app/api/webhooks/razorpay/route.ts +115 -0
- web/app/dashboard-pages/settings/page.tsx +49 -55
- web/components/checkout-button.tsx +103 -0
- web/lib/{stripe.ts β razorpay.ts} +15 -9
- web/lib/supabase/schema.sql +15 -36
- web/package.json +1 -1
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 |
-
#
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
| 12 |
|
| 13 |
# Resend
|
| 14 |
RESEND_API_KEY=re_...
|
| 15 |
|
| 16 |
# App
|
| 17 |
-
NEXT_PUBLIC_SITE_URL=
|
| 18 |
-
CLAUSEGUARD_API_URL=
|
| 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
|
| 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("
|
| 13 |
-
if (!profile?.
|
| 14 |
|
| 15 |
-
const
|
| 16 |
-
const
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
});
|
| 20 |
-
redirect(
|
| 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-
|
| 47 |
<div className="mb-8">
|
| 48 |
-
<Link href="/dashboard-pages/dashboard" className="text-sm text-zinc-400 hover:text-zinc-600">
|
| 49 |
-
|
|
|
|
|
|
|
| 50 |
</div>
|
| 51 |
|
| 52 |
{/* Account */}
|
| 53 |
-
<section className="mb-
|
| 54 |
-
<
|
| 55 |
-
|
| 56 |
-
<
|
| 57 |
-
|
| 58 |
-
|
| 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-
|
| 79 |
-
<
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 88 |
-
|
| 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 |
-
{/*
|
| 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-
|
|
|
|
| 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
|
| 2 |
|
| 3 |
-
let
|
| 4 |
|
| 5 |
-
export function
|
| 6 |
-
if (!
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
| 8 |
}
|
| 9 |
-
return
|
| 10 |
}
|
| 11 |
|
| 12 |
export const PLANS = {
|
| 13 |
free: {
|
| 14 |
name: "Free",
|
| 15 |
scans: 10,
|
| 16 |
-
|
|
|
|
| 17 |
features: ["10 scans per month", "All 8 clause categories", "Risk score and grade"],
|
| 18 |
},
|
| 19 |
pro: {
|
| 20 |
name: "Pro",
|
| 21 |
scans: Infinity,
|
| 22 |
-
|
|
|
|
| 23 |
features: ["Unlimited scans", "Contract uploads", "Clause explanations", "PDF exports"],
|
| 24 |
},
|
| 25 |
team: {
|
| 26 |
name: "Team",
|
| 27 |
scans: Infinity,
|
| 28 |
-
|
|
|
|
| 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 |
-
--
|
| 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 |
-
|
| 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 |
-
--
|
| 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 |
-
--
|
| 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 |
-
--
|
| 39 |
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
|
| 40 |
ALTER TABLE public.analyses ENABLE ROW LEVEL SECURITY;
|
| 41 |
|
| 42 |
-
|
| 43 |
-
CREATE POLICY "Users can
|
| 44 |
-
|
| 45 |
-
|
|
|
|
| 46 |
|
| 47 |
-
|
| 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 |
-
--
|
| 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 |
-
"
|
| 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",
|