From 0895df2db53e9afa30c43b70bbd94e7a2fc4e085 Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 17:10:23 +0530 Subject: [PATCH 01/26] feat(billing): add Razorpay subscription service and webhook handler - Implement core Razorpay service for customer and subscription management - Add webhook handler for subscription lifecycle events (activated, cancelled, paused, etc.) - Create billing API routes for checkout, cancel, pause, resume, and verify - Support PRO and TEAM subscription tiers with monthly/annual billing --- apps/server/prisma/schema.prisma | 6 + apps/server/src/config.ts | 22 + apps/server/src/routes/billing.ts | 271 +++++++++++ apps/server/src/services/razorpay-webhooks.ts | 295 ++++++++++++ apps/server/src/services/razorpay.ts | 432 ++++++++++++++++++ 5 files changed, 1026 insertions(+) create mode 100644 apps/server/src/routes/billing.ts create mode 100644 apps/server/src/services/razorpay-webhooks.ts create mode 100644 apps/server/src/services/razorpay.ts diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 847212c..0576abd 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -20,6 +20,11 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + // Phase 5: Razorpay Billing + razorpayCustomerId String? @unique + razorpaySubscriptionId String? @unique + razorpayCurrentPeriodEnd DateTime? + following Follow[] @relation("Following") followers Follow[] @relation("Followers") teams TeamMember[] @@ -37,6 +42,7 @@ model User { @@index([username]) @@index([githubId]) + @@index([razorpayCustomerId]) } /// Achievement earned by a user (e.g., "Bug Slayer", "100 Day Streak") diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index d307b26..0403aff 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -51,6 +51,28 @@ const envSchema = z SLACK_CLIENT_SECRET: z.string().trim().min(1).optional(), SLACK_SIGNING_SECRET: z.string().trim().min(1).optional(), + /* Razorpay Billing (Required for billing features) */ + RAZORPAY_KEY_ID: z.string().trim().min(1, 'RAZORPAY_KEY_ID is required'), + RAZORPAY_KEY_SECRET: z.string().trim().min(1, 'RAZORPAY_KEY_SECRET is required'), + RAZORPAY_WEBHOOK_SECRET: z.string().trim().min(1, 'RAZORPAY_WEBHOOK_SECRET is required'), + RAZORPAY_PRO_MONTHLY_PLAN_ID: z + .string() + .trim() + .min(1, 'RAZORPAY_PRO_MONTHLY_PLAN_ID is required'), + RAZORPAY_PRO_ANNUAL_PLAN_ID: z + .string() + .trim() + .min(1, 'RAZORPAY_PRO_ANNUAL_PLAN_ID is required'), + RAZORPAY_TEAM_MONTHLY_PLAN_ID: z + .string() + .trim() + .min(1, 'RAZORPAY_TEAM_MONTHLY_PLAN_ID is required'), + RAZORPAY_TEAM_ANNUAL_PLAN_ID: z + .string() + .trim() + .min(1, 'RAZORPAY_TEAM_ANNUAL_PLAN_ID is required'), + WEB_APP_URL: z.string().url().default('http://localhost:3000'), + /* Security & General */ ENCRYPTION_KEY: z.string().min(32, 'ENCRYPTION_KEY must be at least 32 characters'), API_BASE_URL: z.string().url().optional(), diff --git a/apps/server/src/routes/billing.ts b/apps/server/src/routes/billing.ts new file mode 100644 index 0000000..277d4d2 --- /dev/null +++ b/apps/server/src/routes/billing.ts @@ -0,0 +1,271 @@ +/** + * Billing API routes for subscription management. + * + * Provides endpoints for creating subscriptions, managing subscriptions, + * and handling Razorpay webhooks. + */ + +import { z } from 'zod'; + +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + +import { InternalError, ValidationError } from '@/lib/errors'; +import { logger } from '@/lib/logger'; +import { getDb } from '@/services/db'; +import { + isRazorpayEnabled, + createSubscription, + cancelSubscription, + pauseSubscription, + resumeSubscription, + verifyPaymentSignature, + getSubscriptionDetails, +} from '@/services/razorpay'; +import { handleWebhook } from '@/services/razorpay-webhooks'; + +const CheckoutRequestSchema = z.object({ + tier: z.enum(['PRO', 'TEAM']), + billingInterval: z.enum(['monthly', 'annual']), +}); + +const VerifyPaymentSchema = z.object({ + razorpayPaymentId: z.string(), + razorpaySubscriptionId: z.string(), + razorpaySignature: z.string(), +}); + +export function billingRoutes(app: FastifyInstance): void { + const authenticate: typeof app.authenticate = async (request, reply) => { + await app.authenticate(request, reply); + }; + + app.post( + '/checkout', + { onRequest: [authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + if (!isRazorpayEnabled()) { + throw new InternalError('Billing is not configured'); + } + + const body = request.body as { tier?: string; billingInterval?: string }; + const result = CheckoutRequestSchema.safeParse(body); + if (!result.success) { + throw new ValidationError('Invalid request body', { + details: result.error.flatten(), + }); + } + + const { userId } = request.user as { userId: string }; + const { tier, billingInterval } = result.data; + + const checkoutData = await createSubscription(userId, tier, billingInterval); + + return reply.send(checkoutData); + } + ); + + app.post( + '/cancel', + { onRequest: [authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + if (!isRazorpayEnabled()) { + throw new InternalError('Billing is not configured'); + } + + const { userId } = request.user as { userId: string }; + await cancelSubscription(userId); + + return reply.send({ success: true, message: 'Subscription cancelled successfully' }); + } + ); + + app.post( + '/pause', + { onRequest: [authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + if (!isRazorpayEnabled()) { + throw new InternalError('Billing is not configured'); + } + + const { userId } = request.user as { userId: string }; + await pauseSubscription(userId); + + return reply.send({ success: true, message: 'Subscription paused successfully' }); + } + ); + + app.post( + '/resume', + { onRequest: [authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + if (!isRazorpayEnabled()) { + throw new InternalError('Billing is not configured'); + } + + const { userId } = request.user as { userId: string }; + await resumeSubscription(userId); + + return reply.send({ success: true, message: 'Subscription resumed successfully' }); + } + ); + + app.post( + '/verify', + { onRequest: [authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + if (!isRazorpayEnabled()) { + throw new InternalError('Billing is not configured'); + } + + const body = request.body as { + razorpayPaymentId?: string; + razorpaySubscriptionId?: string; + razorpaySignature?: string; + }; + const result = VerifyPaymentSchema.safeParse(body); + if (!result.success) { + throw new ValidationError('Invalid request body', { + details: result.error.flatten(), + }); + } + + const { razorpayPaymentId, razorpaySubscriptionId, razorpaySignature } = result.data; + + const isValid = verifyPaymentSignature( + razorpayPaymentId, + razorpaySubscriptionId, + razorpaySignature + ); + + if (!isValid) { + throw new ValidationError('Invalid payment signature'); + } + + const db = getDb(); + const { userId } = request.user as { userId: string }; + + const subscriptionDetails = await getSubscriptionDetails(razorpaySubscriptionId); + + await db.user.update({ + where: { id: userId }, + data: { + tier: subscriptionDetails.notes.tier as 'PRO' | 'TEAM', + razorpayCurrentPeriodEnd: new Date(subscriptionDetails.current_end * 1000), + }, + }); + + return reply.send({ verified: true }); + } + ); + + app.post( + '/webhooks', + { + config: { rawBody: true } as Record, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + if (!isRazorpayEnabled()) { + return reply.status(503).send({ error: 'Billing not configured' }); + } + + const signature = request.headers['x-razorpay-signature']; + if (!signature || typeof signature !== 'string') { + return reply.status(400).send({ error: 'Missing x-razorpay-signature header' }); + } + + try { + const rawBody = request.rawBody; + if (!rawBody || !Buffer.isBuffer(rawBody)) { + return await reply.status(400).send({ error: 'Missing raw body' }); + } + + await handleWebhook(rawBody, signature); + + return await reply.send({ received: true }); + } catch (error) { + if (error instanceof Error) { + logger.warn({ error: error.message }, 'Webhook processing failed'); + return reply.status(400).send({ error: error.message }); + } + throw error; + } + } + ); + + app.get( + '/status', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + + const db = getDb(); + + const user = await db.user.findUnique({ + where: { id: userId }, + select: { + tier: true, + razorpaySubscriptionId: true, + razorpayCurrentPeriodEnd: true, + }, + }); + + if (!user) { + throw new ValidationError('User not found'); + } + + return reply.send({ + tier: user.tier, + hasSubscription: !!user.razorpaySubscriptionId, + currentPeriodEnd: user.razorpayCurrentPeriodEnd?.toISOString() ?? null, + billingEnabled: isRazorpayEnabled(), + }); + } + ); + + app.get( + '/subscription', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + + const db = getDb(); + + const user = await db.user.findUnique({ + where: { id: userId }, + select: { + tier: true, + razorpaySubscriptionId: true, + razorpayCurrentPeriodEnd: true, + razorpayCustomerId: true, + }, + }); + + if (!user) { + throw new ValidationError('User not found'); + } + + if (!user.razorpaySubscriptionId) { + return reply.send({ + hasSubscription: false, + subscription: null, + }); + } + + const subscriptionDetails = await getSubscriptionDetails(user.razorpaySubscriptionId); + + return reply.send({ + hasSubscription: true, + subscription: { + id: subscriptionDetails.id, + status: subscriptionDetails.status, + tier: subscriptionDetails.notes.tier, + currentPeriodStart: new Date(subscriptionDetails.current_start * 1000).toISOString(), + currentPeriodEnd: new Date(subscriptionDetails.current_end * 1000).toISOString(), + endAt: subscriptionDetails.end_at + ? new Date(subscriptionDetails.end_at * 1000).toISOString() + : null, + }, + }); + } + ); +} diff --git a/apps/server/src/services/razorpay-webhooks.ts b/apps/server/src/services/razorpay-webhooks.ts new file mode 100644 index 0000000..fcc22fa --- /dev/null +++ b/apps/server/src/services/razorpay-webhooks.ts @@ -0,0 +1,295 @@ +/** + * Razorpay webhook handler for subscription lifecycle events. + * + * Handles webhook events from Razorpay for subscription activation, + * cancellation, payment failures, and other subscription events. + */ + +import { logger } from '@/lib/logger'; +import { getDb } from '@/services/db'; +import { verifyWebhookSignature } from '@/services/razorpay'; + +interface RazorpayWebhookPayload { + event: string; + payload: { + subscription?: { id: string; entity: Record }; + payment?: { id: string; entity: Record }; + invoice?: { id: string; entity: Record }; + }; + entity: string; + account_id: string; + created_at: number; +} + +export async function handleWebhook(rawBody: Buffer, signature: string): Promise { + if (!verifyWebhookSignature(rawBody, signature)) { + throw new Error('Invalid webhook signature'); + } + + const payload = JSON.parse(rawBody.toString()) as RazorpayWebhookPayload; + const { event } = payload; + + logger.info({ event }, 'Received Razorpay webhook'); + + const db = getDb(); + + switch (event) { + case 'subscription.activated': + await handleSubscriptionActivated(db, payload.payload.subscription?.entity); + break; + + case 'subscription.cancelled': + await handleSubscriptionCancelled(db, payload.payload.subscription?.entity); + break; + + case 'subscription.completed': + await handleSubscriptionCompleted(db, payload.payload.subscription?.entity); + break; + + case 'subscription.paused': + handleSubscriptionPaused(db, payload.payload.subscription?.entity); + break; + + case 'subscription.resumed': + handleSubscriptionResumed(db, payload.payload.subscription?.entity); + break; + + case 'subscription.pending': + handleSubscriptionPending(db, payload.payload.subscription?.entity); + break; + + case 'payment.succeeded': + handlePaymentSucceeded(db, payload.payload.payment?.entity); + break; + + case 'payment.failed': + handlePaymentFailed(db, payload.payload.payment?.entity); + break; + + case 'invoice.paid': + await handleInvoicePaid(db, payload.payload.invoice?.entity); + break; + + case 'invoice.payment_failed': + handleInvoicePaymentFailed(db, payload.payload.invoice?.entity); + break; + + default: + logger.debug({ event }, 'Unhandled Razorpay event'); + } +} + +async function handleSubscriptionActivated( + db: ReturnType, + subscription: Record | undefined +): Promise { + if (!subscription) { + logger.warn('No subscription data in webhook'); + return; + } + + const subscriptionId = subscription.id as string; + const notes = subscription.notes as Record | undefined; + const userId = notes?.userId; + const tier = notes?.tier as 'PRO' | 'TEAM' | undefined; + const currentEnd = subscription.current_end as number; + + if (!userId) { + logger.warn({ subscriptionId }, 'No userId in subscription notes'); + return; + } + + await db.user.update({ + where: { razorpaySubscriptionId: subscriptionId }, + data: { + tier: tier ?? 'PRO', + razorpayCurrentPeriodEnd: new Date(currentEnd * 1000), + }, + }); + + logger.info({ userId, subscriptionId, tier }, 'Subscription activated via webhook'); +} + +async function handleSubscriptionCancelled( + db: ReturnType, + subscription: Record | undefined +): Promise { + if (!subscription) { + logger.warn('No subscription data in webhook'); + return; + } + + const subscriptionId = subscription.id as string; + const endAt = subscription.end_at as number | null; + + const user = await db.user.findFirst({ + where: { razorpaySubscriptionId: subscriptionId }, + }); + + if (!user) { + logger.warn({ subscriptionId }, 'No user found for subscription cancellation'); + return; + } + + await db.user.update({ + where: { id: user.id }, + data: { + tier: 'FREE', + razorpaySubscriptionId: null, + razorpayCurrentPeriodEnd: endAt ? new Date(endAt * 1000) : null, + }, + }); + + logger.info({ userId: user.id, subscriptionId }, 'Subscription cancelled via webhook'); +} + +async function handleSubscriptionCompleted( + db: ReturnType, + subscription: Record | undefined +): Promise { + if (!subscription) { + logger.warn('No subscription data in webhook'); + return; + } + + const subscriptionId = subscription.id as string; + + const user = await db.user.findFirst({ + where: { razorpaySubscriptionId: subscriptionId }, + }); + + if (!user) { + logger.warn({ subscriptionId }, 'No user found for completed subscription'); + return; + } + + await db.user.update({ + where: { id: user.id }, + data: { + tier: 'FREE', + razorpaySubscriptionId: null, + razorpayCurrentPeriodEnd: null, + }, + }); + + logger.info({ userId: user.id, subscriptionId }, 'Subscription completed via webhook'); +} + +function handleSubscriptionPaused( + _db: ReturnType, + subscription: Record | undefined +): void { + if (!subscription) { + logger.warn('No subscription data in webhook'); + return; + } + + const subscriptionId = subscription.id as string; + const pausedAt = subscription.paused_at as number | undefined; + + logger.info({ subscriptionId, pausedAt }, 'Subscription paused'); +} + +function handleSubscriptionResumed( + _db: ReturnType, + subscription: Record | undefined +): void { + if (!subscription) { + logger.warn('No subscription data in webhook'); + return; + } + + const subscriptionId = subscription.id as string; + const currentEnd = subscription.current_end as number; + + logger.info({ subscriptionId, currentEnd }, 'Subscription resumed'); +} + +function handleSubscriptionPending( + _db: ReturnType, + subscription: Record | undefined +): void { + if (!subscription) { + logger.warn('No subscription data in webhook'); + return; + } + + const subscriptionId = subscription.id as string; + + logger.info({ subscriptionId }, 'Subscription is pending'); +} + +function handlePaymentSucceeded( + _db: ReturnType, + payment: Record | undefined +): void { + if (!payment) { + logger.warn('No payment data in webhook'); + return; + } + + const paymentId = payment.id as string; + const amount = payment.amount as number; + const currency = payment.currency as string; + + logger.info({ paymentId, amount, currency }, 'Payment succeeded'); +} + +function handlePaymentFailed( + _db: ReturnType, + payment: Record | undefined +): void { + if (!payment) { + logger.warn('No payment data in webhook'); + return; + } + + const paymentId = payment.id as string; + const amount = payment.amount as number; + const errorCode = payment.error_code as string | undefined; + const errorDescription = payment.error_description as string | undefined; + + logger.warn({ paymentId, amount, errorCode, errorDescription }, 'Payment failed'); +} + +async function handleInvoicePaid( + db: ReturnType, + invoice: Record | undefined +): Promise { + if (!invoice) { + logger.warn('No invoice data in webhook'); + return; + } + + const invoiceId = invoice.id as string; + const amount = invoice.amount as number; + const subscriptionId = invoice.subscription_id as string | undefined; + + logger.info({ invoiceId, amount, subscriptionId }, 'Invoice paid'); + + if (subscriptionId) { + const subscription = await db.user.findFirst({ + where: { razorpaySubscriptionId: subscriptionId }, + }); + + if (subscription) { + logger.info({ userId: subscription.id, invoiceId }, 'Invoice paid for subscription'); + } + } +} + +function handleInvoicePaymentFailed( + _db: ReturnType, + invoice: Record | undefined +): void { + if (!invoice) { + logger.warn('No invoice data in webhook'); + return; + } + + const invoiceId = invoice.id as string; + const amount = invoice.amount as number; + const subscriptionId = invoice.subscription_id as string | undefined; + + logger.warn({ invoiceId, amount, subscriptionId }, 'Invoice payment failed'); +} diff --git a/apps/server/src/services/razorpay.ts b/apps/server/src/services/razorpay.ts new file mode 100644 index 0000000..a4d9cfb --- /dev/null +++ b/apps/server/src/services/razorpay.ts @@ -0,0 +1,432 @@ +/** + * Razorpay billing service for subscription management. + */ + +import crypto from 'crypto'; + +import { env } from '@/config'; +import { ValidationError, InternalError } from '@/lib/errors'; +import { logger } from '@/lib/logger'; +import { getDb } from '@/services/db'; + +export interface UserForBilling { + id: string; + email: string | null; + displayName: string | null; + username: string; + githubId: string; + razorpayCustomerId: string | null; + razorpaySubscriptionId: string | null; +} + +type BillingInterval = 'monthly' | 'annual'; +type SubscriptionTier = 'PRO' | 'TEAM'; + +interface RazorpayConfig { + keyId: string; + keySecret: string; + webhookSecret: string; + plans: { + PRO: { monthly: string; annual: string }; + TEAM: { monthly: string; annual: string }; + }; + webAppUrl: string; +} + +interface RazorpaySubscriptionResponse { + id: string; + status: string; + current_end: number; + current_start: number; + end_at: number | null; + notes: Record; +} + +let razorpayClient: Record | null = null; +let razorpayConfig: RazorpayConfig | null = null; + +export function isRazorpayEnabled(): boolean { + return !!( + env.RAZORPAY_KEY_ID && + env.RAZORPAY_KEY_SECRET && + env.RAZORPAY_WEBHOOK_SECRET && + env.RAZORPAY_PRO_MONTHLY_PLAN_ID && + env.RAZORPAY_PRO_ANNUAL_PLAN_ID && + env.RAZORPAY_TEAM_MONTHLY_PLAN_ID && + env.RAZORPAY_TEAM_ANNUAL_PLAN_ID && + env.WEB_APP_URL + ); +} + +async function getClient(): Promise> { + if (!isRazorpayEnabled()) { + throw new InternalError('Razorpay billing is not configured'); + } + + if (!razorpayClient) { + const Razorpay = (await import('razorpay')).default; + razorpayClient = new Razorpay({ + key_id: env.RAZORPAY_KEY_ID, + key_secret: env.RAZORPAY_KEY_SECRET, + }) as unknown as Record; + } + + return razorpayClient; +} + +function getConfig(): RazorpayConfig { + if (!razorpayConfig) { + if (!isRazorpayEnabled()) { + throw new InternalError('Razorpay billing is not configured'); + } + + razorpayConfig = { + keyId: env.RAZORPAY_KEY_ID, + keySecret: env.RAZORPAY_KEY_SECRET, + webhookSecret: env.RAZORPAY_WEBHOOK_SECRET, + plans: { + PRO: { + monthly: env.RAZORPAY_PRO_MONTHLY_PLAN_ID, + annual: env.RAZORPAY_PRO_ANNUAL_PLAN_ID, + }, + TEAM: { + monthly: env.RAZORPAY_TEAM_MONTHLY_PLAN_ID, + annual: env.RAZORPAY_TEAM_ANNUAL_PLAN_ID, + }, + }, + webAppUrl: env.WEB_APP_URL, + }; + } + + return razorpayConfig; +} + +export function getPlanId(tier: SubscriptionTier, interval: BillingInterval): string { + const config = getConfig(); + return config.plans[tier][interval]; +} + +export async function getOrCreateCustomer(user: UserForBilling): Promise { + const client = await getClient(); + const clientCasted = client as { + customers: { + all: () => Promise<{ items: { id: string; email: string | undefined }[] }>; + create: (data: Record) => Promise<{ id: string }>; + }; + }; + const db = getDb(); + + if (user.razorpayCustomerId) { + return user.razorpayCustomerId; + } + + if (user.email) { + try { + const customersList = await clientCasted.customers.all(); + const existingCustomer = customersList.items.find((c) => c.email === user.email); + if (existingCustomer) { + await db.user.update({ + where: { id: user.id }, + data: { razorpayCustomerId: existingCustomer.id }, + }); + logger.info( + { userId: user.id, customerId: existingCustomer.id }, + 'Found existing Razorpay customer' + ); + return existingCustomer.id; + } + } catch { + logger.warn( + { userId: user.id, error: 'Failed to search customers' }, + 'Customer search failed' + ); + } + } + + const customerData: Record = { + name: user.displayName ?? user.username, + notes: { + userId: user.id, + githubId: user.githubId, + username: user.username, + }, + }; + if (user.email) { + customerData.email = user.email; + } + + const customer = await clientCasted.customers.create(customerData); + + await db.user.update({ + where: { id: user.id }, + data: { razorpayCustomerId: customer.id }, + }); + + logger.info({ userId: user.id, customerId: customer.id }, 'Created Razorpay customer'); + + return customer.id; +} + +export async function createSubscription( + userId: string, + tier: SubscriptionTier, + billingInterval: BillingInterval +): Promise<{ subscriptionId: string; orderId: string; keyId: string }> { + const client = await getClient(); + const clientCasted = client as { + subscriptions: { + create: ( + data: Record + ) => Promise<{ id: string; notes: Record }>; + }; + orders: { + create: (data: Record) => Promise<{ id: string }>; + }; + }; + const config = getConfig(); + const db = getDb(); + + const user = await db.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new ValidationError('User not found'); + } + + if (user.razorpaySubscriptionId) { + throw new ValidationError( + 'User already has an active subscription. Please manage it from your dashboard.' + ); + } + + const customerId = await getOrCreateCustomer({ + id: user.id, + email: user.email, + displayName: user.displayName, + username: user.username, + githubId: user.githubId, + razorpayCustomerId: user.razorpayCustomerId, + razorpaySubscriptionId: user.razorpaySubscriptionId, + }); + + const planId = config.plans[tier][billingInterval]; + + const subscriptionData: Record = { + customer_id: customerId, + plan_id: planId, + total_count: billingInterval === 'annual' ? '5' : '12', + notes: { + userId: user.id, + tier, + }, + }; + + const subscription = await clientCasted.subscriptions.create(subscriptionData); + + const order = await clientCasted.orders.create({ + amount: (subscription.notes.total_amount as number | undefined) ?? 0, + currency: 'INR', + receipt: `sub_${subscription.id}`, + }); + + await db.user.update({ + where: { id: userId }, + data: { + razorpaySubscriptionId: subscription.id, + }, + }); + + logger.info( + { userId, tier, billingInterval, subscriptionId: subscription.id, orderId: order.id }, + 'Created Razorpay subscription' + ); + + return { + subscriptionId: subscription.id, + orderId: order.id, + keyId: config.keyId, + }; +} + +export async function cancelSubscription(userId: string): Promise { + const client = await getClient(); + const clientCasted = client as { + subscriptions: { + cancel: (id: string) => Promise; + }; + }; + const db = getDb(); + + const user = await db.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new ValidationError('User not found'); + } + + if (!user.razorpaySubscriptionId) { + throw new ValidationError('No active subscription found'); + } + + try { + await clientCasted.subscriptions.cancel(user.razorpaySubscriptionId); + + await db.user.update({ + where: { id: userId }, + data: { + tier: 'FREE', + razorpaySubscriptionId: null, + razorpayCurrentPeriodEnd: null, + }, + }); + + logger.info( + { userId, subscriptionId: user.razorpaySubscriptionId }, + 'Cancelled Razorpay subscription' + ); + } catch (error) { + logger.error( + { userId, subscriptionId: user.razorpaySubscriptionId, error }, + 'Failed to cancel subscription' + ); + throw new InternalError('Failed to cancel subscription'); + } +} + +export async function pauseSubscription(userId: string): Promise { + const client = await getClient(); + const clientCasted = client as { + subscriptions: { + pause: (id: string) => Promise; + }; + }; + + const db = getDb(); + + const user = await db.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new ValidationError('User not found'); + } + + if (!user.razorpaySubscriptionId) { + throw new ValidationError('No active subscription found'); + } + + await clientCasted.subscriptions.pause(user.razorpaySubscriptionId); + + logger.info( + { userId, subscriptionId: user.razorpaySubscriptionId }, + 'Paused Razorpay subscription' + ); +} + +export async function resumeSubscription(userId: string): Promise { + const client = await getClient(); + const clientCasted = client as { + subscriptions: { + resume: (id: string) => Promise; + }; + }; + + const db = getDb(); + const user = await db.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new ValidationError('User not found'); + } + + if (!user.razorpaySubscriptionId) { + throw new ValidationError('No subscription found'); + } + + await clientCasted.subscriptions.resume(user.razorpaySubscriptionId); + + logger.info( + { userId, subscriptionId: user.razorpaySubscriptionId }, + 'Resumed Razorpay subscription' + ); +} + +export async function getSubscriptionDetails( + subscriptionId: string +): Promise { + const client = await getClient(); + const clientCasted = client as { + subscriptions: { + fetch: (id: string) => Promise<{ + id: string; + status: string; + current_end: number; + current_start: number; + end_at: number | null | undefined; + notes: Record; + }>; + }; + }; + + const subscription = await clientCasted.subscriptions.fetch(subscriptionId); + + return { + id: subscription.id, + status: subscription.status, + current_end: subscription.current_end, + current_start: subscription.current_start, + end_at: subscription.end_at ?? null, + notes: subscription.notes as Record, + }; +} + +export async function getCustomerSubscriptions( + customerId: string +): Promise { + const client = await getClient(); + const clientCasted = client as { + subscriptions: { + all: (data: Record) => Promise<{ + items: { + id: string; + status: string; + current_end: number; + current_start: number; + end_at: number | null | undefined; + notes: Record; + }[]; + }>; + }; + }; + + const subscriptionsList = await clientCasted.subscriptions.all({ + customer_id: customerId, + }); + + return subscriptionsList.items.map((sub) => ({ + id: sub.id, + status: sub.status, + current_end: sub.current_end, + current_start: sub.current_start, + end_at: sub.end_at ?? null, + notes: sub.notes as Record, + })); +} + +export function verifyPaymentSignature( + razorpayPaymentId: string, + razorpaySubscriptionId: string, + razorpaySignature: string +): boolean { + const config = getConfig(); + + const payload = `${razorpayPaymentId}|${razorpaySubscriptionId}`; + const expectedSignature = crypto + .createHmac('sha256', config.keySecret) + .update(payload) + .digest('hex'); + + return expectedSignature === razorpaySignature; +} + +export function verifyWebhookSignature(rawBody: Buffer, signature: string): boolean { + const config = getConfig(); + + const expectedSignature = crypto + .createHmac('sha256', config.webhookSecret) + .update(rawBody) + .digest('hex'); + + return signature === expectedSignature; +} From aefe23ef9abe8fabe9b88f17bac425e86696d0aa Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 17:10:50 +0530 Subject: [PATCH 02/26] feat(billing): configure Razorpay environment and database schema - Add Razorpay environment variables to config - Update Prisma schema with razorpayCustomerId, razorpaySubscriptionId, razorpayCurrentPeriodEnd - Add Razorpay keys to .env.example documentation --- .env.example | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 60f4550..458caeb 100644 --- a/.env.example +++ b/.env.example @@ -29,8 +29,26 @@ WS_PORT=3001 # Logging LOG_LEVEL=debug -# Slack Integration (Phase 3 - Optional) +# Slack Integration (Optional) # Create a Slack App at https://api.slack.com/apps SLACK_CLIENT_ID= SLACK_CLIENT_SECRET= SLACK_SIGNING_SECRET= + +# Razorpay Billing +# Get your API keys from https://dashboard.razorpay.com/#/app/keys +RAZORPAY_KEY_ID=your_razorpay_key_id +RAZORPAY_KEY_SECRET=your_razorpay_key_secret +RAZORPAY_WEBHOOK_SECRET=your_razorpay_webhook_secret + +# Razorpay Plan IDs +RAZORPAY_PRO_MONTHLY_PLAN_ID=plan_pro_monthly +RAZORPAY_PRO_ANNUAL_PLAN_ID=plan_pro_annual +RAZORPAY_TEAM_MONTHLY_PLAN_ID=plan_team_monthly +RAZORPAY_TEAM_ANNUAL_PLAN_ID=plan_team_annual + +# Web App URL (for redirect URLs) +WEB_APP_URL=http://localhost:3000 + +# Frontend Environment Variables +NEXT_PUBLIC_RAZORPAY_KEY_ID=your_razorpay_key_id From afe326d83eed2322ad7e074511c069696d9e53d2 Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 17:11:14 +0530 Subject: [PATCH 03/26] feat(frontend): add billing dashboard page with Razorpay checkout - Create new billing dashboard page at /dashboard/billing - Implement Razorpay Standard Checkout integration - Update pricing constants with INR tiers (PRO: 99/588, TEAM: 499/2988) - Add tier badge component and subscription management UI --- apps/web/src/app/dashboard/billing/page.tsx | 533 ++++++++++++++++++++ apps/web/src/lib/constants.ts | 17 +- 2 files changed, 547 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/app/dashboard/billing/page.tsx diff --git a/apps/web/src/app/dashboard/billing/page.tsx b/apps/web/src/app/dashboard/billing/page.tsx new file mode 100644 index 0000000..708b6d5 --- /dev/null +++ b/apps/web/src/app/dashboard/billing/page.tsx @@ -0,0 +1,533 @@ +'use client'; + +import { useState, useEffect, Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { + Check, + X, + ArrowRight, + CreditCard, + Settings, + Zap, + Crown, + Users, + Loader2, + CheckCircle, + XCircle, + ExternalLink, +} from 'lucide-react'; + +import { Container } from '@/components/layout'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { PRICING_TIERS, SITE_CONFIG } from '@/lib/constants'; +import { cn } from '@/lib/utils'; + +interface BillingStatus { + tier: 'FREE' | 'PRO' | 'TEAM'; + hasSubscription: boolean; + currentPeriodEnd: string | null; + billingEnabled: boolean; +} + +function loadRazorpayScript(): Promise { + return new Promise((resolve, reject) => { + if ((window as unknown as { Razorpay?: unknown }).Razorpay) { + resolve(); + return; + } + const script = document.createElement('script'); + script.src = 'https://checkout.razorpay.com/v1/checkout.js'; + script.async = true; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Failed to load Razorpay')); + document.body.appendChild(script); + }); +} + +function BillingPageContent() { + const searchParams = useSearchParams(); + const [isAnnual, setIsAnnual] = useState(false); + const [loading, setLoading] = useState(null); + const [billingStatus, setBillingStatus] = useState({ + tier: 'FREE', + hasSubscription: false, + currentPeriodEnd: null, + billingEnabled: true, + }); + + const success = searchParams.get('success') === 'true'; + const canceled = searchParams.get('canceled') === 'true'; + const upgradeTo = searchParams.get('upgrade') as 'PRO' | 'TEAM' | null; + + useEffect(() => { + fetchBillingStatus(); + }, []); + + const fetchBillingStatus = async () => { + try { + const response = await fetch('/api/billing/status'); + if (response.ok) { + const data = await response.json(); + setBillingStatus({ + tier: data.tier, + hasSubscription: data.hasSubscription, + currentPeriodEnd: data.currentPeriodEnd, + billingEnabled: data.billingEnabled, + }); + } + } catch (error) { + console.error('Failed to fetch billing status:', error); + } + }; + + const handleCheckout = async (tier: 'PRO' | 'TEAM') => { + setLoading(tier); + try { + await loadRazorpayScript(); + + const response = await fetch('/api/billing/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + tier, + billingInterval: isAnnual ? 'annual' : 'monthly', + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to create checkout'); + } + + const { subscriptionId, orderId, keyId } = await response.json(); + + const options = { + key: keyId, + name: 'DevRadar', + description: `${tier} Plan - ${isAnnual ? 'Annual' : 'Monthly'}`, + order_id: orderId, + subscription_id: subscriptionId, + handler: async (response: { + razorpay_payment_id: string; + razorpay_subscription_id: string; + razorpay_signature: string; + }) => { + try { + const verifyResponse = await fetch('/api/billing/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + razorpayPaymentId: response.razorpay_payment_id, + razorpaySubscriptionId: response.razorpay_subscription_id, + razorpaySignature: response.razorpay_signature, + }), + }); + + if (verifyResponse.ok) { + window.location.href = '/dashboard/billing?success=true'; + } else { + window.location.href = '/dashboard/billing?canceled=true'; + } + } catch (error) { + window.location.href = '/dashboard/billing?canceled=true'; + } + }, + prefill: { + name: '', + email: '', + }, + theme: { + color: '#2563eb', + }, + }; + + const razorpay = new ( + window as unknown as { Razorpay: new (options: unknown) => { open: () => void } } + ).Razorpay(options); + razorpay.open(); + } catch (error) { + console.error('Checkout failed:', error); + alert('Failed to initialize checkout. Please try again.'); + } finally { + setLoading(null); + } + }; + + const handleManageSubscription = async () => { + setLoading('portal'); + try { + const response = await fetch('/api/billing/status'); + if (response.ok) { + const data = await response.json(); + if (data.hasSubscription) { + alert( + 'Subscription management is available through the Razorpay dashboard. Contact support@devradar.dev for assistance.' + ); + } + } + } catch { + console.error('Failed to check subscription status'); + } finally { + setLoading(null); + } + }; + + const handleCancelSubscription = async () => { + if ( + !confirm( + 'Are you sure you want to cancel your subscription? You will lose access to premium features at the end of your billing period.' + ) + ) { + return; + } + + setLoading('cancel'); + try { + const response = await fetch('/api/billing/cancel', { + method: 'POST', + }); + + if (response.ok) { + fetchBillingStatus(); + alert('Subscription cancelled successfully'); + } else { + throw new Error('Failed to cancel subscription'); + } + } catch (error) { + console.error('Cancel failed:', error); + alert('Failed to cancel subscription. Please try again.'); + } finally { + setLoading(null); + } + }; + + const getTierIcon = (tier: string) => { + switch (tier) { + case 'FREE': + return ; + case 'PRO': + return ; + case 'TEAM': + return ; + default: + return ; + } + }; + + return ( +
+ + {success && ( +
+ +
+

Subscription activated!

+

+ Your account has been upgraded. Enjoy your new features! +

+
+
+ )} + + {canceled && ( +
+ +
+

Checkout canceled

+

No worries! You can upgrade anytime.

+
+
+ )} + +
+

Billing & Subscription

+

+ Manage your DevRadar subscription and billing settings. +

+
+ + + +
+
+
+ {getTierIcon(billingStatus.tier)} +
+
+ + {billingStatus.tier} Plan + {billingStatus.tier !== 'FREE' && ( + + Active + + )} + + + {billingStatus.tier === 'FREE' + ? 'Free forever, upgrade anytime' + : billingStatus.currentPeriodEnd + ? `Renews on ${new Date(billingStatus.currentPeriodEnd).toLocaleDateString()}` + : 'Active subscription'} + +
+
+ {billingStatus.hasSubscription && ( +
+ + +
+ )} +
+
+ {billingStatus.tier === 'FREE' && ( + +

+ Upgrade to unlock unlimited friends, ghost mode, custom themes, and more. +

+
+ )} +
+ +
+ + Monthly + + + + Annual + -50% + +
+ +
+ {PRICING_TIERS.map((tier) => { + const price = isAnnual ? Math.round(tier.price * 0.5) : tier.price; + const isCurrentPlan = tier.id.toUpperCase() === billingStatus.tier; + const isHighlighted = tier.highlighted || tier.id.toUpperCase() === upgradeTo; + const canUpgrade = + (tier.id === 'pro' && billingStatus.tier === 'FREE') || + (tier.id === 'team' && billingStatus.tier !== 'TEAM'); + + return ( + + {isCurrentPlan && ( +
+ Current Plan +
+ )} + +
+ {getTierIcon(tier.id.toUpperCase())} + {tier.name} +
+ {tier.description} +
+ {price === 0 ? 'Free' : `$${price}`} + {price > 0 && ( + + /{'priceNote' in tier && tier.priceNote ? tier.priceNote : 'mo'} + + )} +
+ {isAnnual && price > 0 && ( +

Save 50% with annual

+ )} +
+ +
    + {tier.features.map((feature) => ( +
  • + {feature.included ? ( + + ) : ( + + )} + + {feature.text} + +
  • + ))} +
+ + {tier.id === 'free' ? ( + isCurrentPlan ? ( + + ) : ( + + ) + ) : tier.id === 'team' ? ( + isCurrentPlan ? ( + + ) : ( + + ) + ) : isCurrentPlan ? ( + + ) : ( + + )} +
+
+ ); + })} +
+ + + + Billing FAQ + + +
+

How do I cancel my subscription?

+

+ Click the "Cancel" button above to cancel your subscription. You'll + retain access until the end of your billing period. +

+
+
+

Can I switch between plans?

+

+ Yes! You can upgrade or downgrade at any time. When upgrading, you'll be + charged the prorated difference. When downgrading, you'll receive credit toward + future billing. +

+
+
+

What payment methods do you accept?

+

+ We accept all major credit cards (Visa, Mastercard, American Express), UPI, net + banking, and popular wallets through our secure payment processor, Razorpay. +

+
+
+

Need help?

+

+ Contact us at{' '} + + {SITE_CONFIG.email.support} + {' '} + for billing questions or issues. +

+
+
+
+
+
+ ); +} + +export default function BillingPage() { + return ( + + + + } + > + + + ); +} diff --git a/apps/web/src/lib/constants.ts b/apps/web/src/lib/constants.ts index 92e5c91..edfef70 100644 --- a/apps/web/src/lib/constants.ts +++ b/apps/web/src/lib/constants.ts @@ -97,6 +97,7 @@ export const PRICING_TIERS = [ id: 'free', name: 'Free', price: 0, + annualPrice: 0, description: 'Perfect for solo developers', features: [ { text: 'Real-time presence', included: true }, @@ -114,8 +115,13 @@ export const PRICING_TIERS = [ { id: 'pro', name: 'Pro', - price: 2, + price: 99, + annualPrice: 588, // ₹49/month billed annually (₹588/year) description: 'For serious developers', + razorpayPlanIds: { + monthly: process.env.NEXT_PUBLIC_RAZORPAY_PRO_MONTHLY_PLAN_ID || 'plan_pro_monthly', + annual: process.env.NEXT_PUBLIC_RAZORPAY_PRO_ANNUAL_PLAN_ID || 'plan_pro_annual', + }, features: [ { text: 'Everything in Free', included: true }, { text: 'Unlimited friends', included: true }, @@ -132,9 +138,14 @@ export const PRICING_TIERS = [ { id: 'team', name: 'Team', - price: 7, + price: 499, + annualPrice: 2988, // ₹249/month billed annually (₹2988/year) priceNote: 'per user', description: 'For distributed teams', + razorpayPlanIds: { + monthly: process.env.NEXT_PUBLIC_RAZORPAY_TEAM_MONTHLY_PLAN_ID || 'plan_team_monthly', + annual: process.env.NEXT_PUBLIC_RAZORPAY_TEAM_ANNUAL_PLAN_ID || 'plan_team_annual', + }, features: [ { text: 'Everything in Pro', included: true }, { text: 'Merge conflict radar', included: true }, @@ -145,7 +156,7 @@ export const PRICING_TIERS = [ { text: 'Admin controls', included: true }, { text: 'Dedicated support', included: true }, ], - cta: 'Contact Sales', + cta: 'Upgrade to Team', highlighted: false, }, ] as const; From 4b1120b0bd3920f4786e7e78fc443a616f2fc39d Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 17:11:33 +0530 Subject: [PATCH 04/26] feat(gating): add feature gating infrastructure - Implement feature gate service for extension - Add server-side feature gate utilities - Define feature flags in shared constants - Support tier-based feature access (FREE, PRO, TEAM) --- .../src/services/featureGatingService.ts | 234 +++++++++++++++++ apps/server/src/lib/featureGate.ts | 236 ++++++++++++++++++ packages/shared/src/features.ts | 212 ++++++++++++++++ 3 files changed, 682 insertions(+) create mode 100644 apps/extension/src/services/featureGatingService.ts create mode 100644 apps/server/src/lib/featureGate.ts create mode 100644 packages/shared/src/features.ts diff --git a/apps/extension/src/services/featureGatingService.ts b/apps/extension/src/services/featureGatingService.ts new file mode 100644 index 0000000..736cba4 --- /dev/null +++ b/apps/extension/src/services/featureGatingService.ts @@ -0,0 +1,234 @@ +/** + * Feature Gating Service + * + * Client-side feature access control for the VS Code extension. + * Checks user tier and prompts for upgrade when accessing gated features. + */ + +import * as vscode from 'vscode'; + +import type { AuthService } from './authService'; +import type { ConfigManager } from '../utils/configManager'; +import type { Logger } from '../utils/logger'; + +type Feature = + | 'presence' + | 'friends' + | 'globalLeaderboard' + | 'friendsLeaderboard' + | 'streaks' + | 'achievements' + | 'poke' + | 'privacyMode' + | 'unlimitedFriends' + | 'ghostMode' + | 'customStatus' + | 'history30d' + | 'themes' + | 'customEmoji' + | 'prioritySupport' + | 'conflictRadar' + | 'teamCreation' + | 'teamAnalytics' + | 'slackIntegration' + | 'privateLeaderboards' + | 'adminControls' + | 'ssoSaml' + | 'dedicatedSupport'; + +type SubscriptionTier = 'FREE' | 'PRO' | 'TEAM'; + +const FREE_FEATURES: readonly Feature[] = [ + 'presence', + 'friends', + 'globalLeaderboard', + 'friendsLeaderboard', + 'streaks', + 'achievements', + 'poke', + 'privacyMode', +]; + +const PRO_ADDITIONAL: readonly Feature[] = [ + 'unlimitedFriends', + 'ghostMode', + 'customStatus', + 'history30d', + 'themes', + 'customEmoji', + 'prioritySupport', +]; + +const TEAM_ADDITIONAL: readonly Feature[] = [ + 'conflictRadar', + 'teamCreation', + 'teamAnalytics', + 'slackIntegration', + 'privateLeaderboards', + 'adminControls', + 'ssoSaml', + 'dedicatedSupport', +]; + +const SUBSCRIPTION_FEATURES: Record = { + FREE: FREE_FEATURES, + PRO: [...FREE_FEATURES, ...PRO_ADDITIONAL], + TEAM: [...FREE_FEATURES, ...PRO_ADDITIONAL, ...TEAM_ADDITIONAL], +}; + +const FEATURE_TIER_MAP: Record = { + presence: 'FREE', + friends: 'FREE', + globalLeaderboard: 'FREE', + friendsLeaderboard: 'FREE', + streaks: 'FREE', + achievements: 'FREE', + poke: 'FREE', + privacyMode: 'FREE', + unlimitedFriends: 'PRO', + ghostMode: 'PRO', + customStatus: 'PRO', + history30d: 'PRO', + themes: 'PRO', + customEmoji: 'PRO', + prioritySupport: 'PRO', + conflictRadar: 'TEAM', + teamCreation: 'TEAM', + teamAnalytics: 'TEAM', + slackIntegration: 'TEAM', + privateLeaderboards: 'TEAM', + adminControls: 'TEAM', + ssoSaml: 'TEAM', + dedicatedSupport: 'TEAM', +}; + +const FEATURE_DESCRIPTIONS: Record = { + presence: 'Real-time presence status', + friends: 'Friends list with activity', + globalLeaderboard: 'Global coding leaderboards', + friendsLeaderboard: 'Friends leaderboard', + streaks: 'Coding streak tracking', + achievements: 'GitHub achievements', + poke: 'Poke friends', + privacyMode: 'Hide activity details', + unlimitedFriends: 'Unlimited friends', + ghostMode: 'Go completely invisible', + customStatus: 'Custom status messages', + history30d: '30-day activity history', + themes: 'Custom themes', + customEmoji: 'Custom emoji reactions', + prioritySupport: 'Priority support', + conflictRadar: 'Merge conflict detection', + teamCreation: 'Create and manage teams', + teamAnalytics: 'Team analytics dashboard', + slackIntegration: 'Slack integration', + privateLeaderboards: 'Private team leaderboards', + adminControls: 'Admin controls', + ssoSaml: 'SSO & SAML authentication', + dedicatedSupport: 'Dedicated support', +}; + +/** Manages feature access control and upgrade prompts. */ +export class FeatureGatingService implements vscode.Disposable { + private readonly disposables: vscode.Disposable[] = []; + + constructor( + private readonly authService: AuthService, + private readonly configManager: ConfigManager, + private readonly logger: Logger + ) {} + + /** + * Checks if the current user has access to a feature. + * @param feature - The feature to check access for + * @returns true if the user has access + */ + hasAccess(feature: Feature): boolean { + const user = this.authService.getUser(); + if (!user) { + return false; + } + + // Defensive: ensure tier is valid, default to FREE if not + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime value might differ from type + const tier = (user.tier ?? 'FREE') as SubscriptionTier; + return SUBSCRIPTION_FEATURES[tier].includes(feature); + } + + /** + * Gets the minimum tier required for a feature. + * @param feature - The feature to check + * @returns The minimum tier required + */ + getRequiredTier(feature: Feature): SubscriptionTier { + return FEATURE_TIER_MAP[feature]; + } + + /** + * Gets the user's current tier. + * @returns The user's tier or 'FREE' if not authenticated + */ + getCurrentTier(): SubscriptionTier { + const user = this.authService.getUser(); + return (user?.tier ?? 'FREE') as SubscriptionTier; + } + + /** + * Prompts the user to upgrade if they don't have access to a feature. + * Opens the billing page in the browser with upgrade parameters. + * + * @param feature - The feature requiring upgrade + * @returns true if the user has access, false if they need to upgrade + */ + async promptUpgrade(feature: Feature): Promise { + if (this.hasAccess(feature)) { + return true; + } + + const requiredTier = this.getRequiredTier(feature); + const featureDescription = FEATURE_DESCRIPTIONS[feature]; + + const action = await vscode.window.showWarningMessage( + `DevRadar: "${featureDescription}" requires ${requiredTier} tier.`, + 'Upgrade Now', + 'Maybe Later' + ); + + if (action === 'Upgrade Now') { + const webAppUrl = this.getWebAppUrl(); + const upgradeUrl = `${webAppUrl}/dashboard/billing?upgrade=${requiredTier}&feature=${feature}`; + + await vscode.env.openExternal(vscode.Uri.parse(upgradeUrl)); + this.logger.info('Opened upgrade page', { feature, requiredTier }); + } + + return false; + } + + /** + * Gets the upgrade URL for a specific tier. + * @param tier - The target tier + * @returns The full upgrade URL + */ + getUpgradeUrl(tier: SubscriptionTier): string { + const webAppUrl = this.getWebAppUrl(); + return `${webAppUrl}/dashboard/billing?upgrade=${tier}`; + } + + /** + * Gets the web app URL from config or uses default. + */ + private getWebAppUrl(): string { + const serverUrl = this.configManager.get('serverUrl'); + if (serverUrl.includes('localhost') || serverUrl.includes('127.0.0.1')) { + return 'http://localhost:3001'; + } + return 'https://devradar.dev'; + } + + dispose(): void { + for (const disposable of this.disposables) { + disposable.dispose(); + } + } +} diff --git a/apps/server/src/lib/featureGate.ts b/apps/server/src/lib/featureGate.ts new file mode 100644 index 0000000..095a9c8 --- /dev/null +++ b/apps/server/src/lib/featureGate.ts @@ -0,0 +1,236 @@ +/** + * Feature gate utilities for tier-based access control. + * + * Provides Fastify preHandler hooks for restricting route access + * based on user subscription tier. + */ + +import type { FastifyRequest, FastifyReply, preHandlerHookHandler } from 'fastify'; + +import { AuthorizationError } from '@/lib/errors'; +import { getDb } from '@/services/db'; + +type Feature = + | 'presence' + | 'friends' + | 'globalLeaderboard' + | 'friendsLeaderboard' + | 'streaks' + | 'achievements' + | 'poke' + | 'privacyMode' + | 'unlimitedFriends' + | 'ghostMode' + | 'customStatus' + | 'history30d' + | 'themes' + | 'customEmoji' + | 'prioritySupport' + | 'conflictRadar' + | 'teamCreation' + | 'teamAnalytics' + | 'slackIntegration' + | 'privateLeaderboards' + | 'adminControls' + | 'ssoSaml' + | 'dedicatedSupport'; + +type SubscriptionTier = 'FREE' | 'PRO' | 'TEAM'; + +const FREE_FEATURES: readonly Feature[] = [ + 'presence', + 'friends', + 'globalLeaderboard', + 'friendsLeaderboard', + 'streaks', + 'achievements', + 'poke', + 'privacyMode', +]; + +const PRO_ADDITIONAL: readonly Feature[] = [ + 'unlimitedFriends', + 'ghostMode', + 'customStatus', + 'history30d', + 'themes', + 'customEmoji', + 'prioritySupport', +]; + +const TEAM_ADDITIONAL: readonly Feature[] = [ + 'conflictRadar', + 'teamCreation', + 'teamAnalytics', + 'slackIntegration', + 'privateLeaderboards', + 'adminControls', + 'ssoSaml', + 'dedicatedSupport', +]; + +const SUBSCRIPTION_FEATURES: Record = { + FREE: FREE_FEATURES, + PRO: [...FREE_FEATURES, ...PRO_ADDITIONAL], + TEAM: [...FREE_FEATURES, ...PRO_ADDITIONAL, ...TEAM_ADDITIONAL], +}; + +const FEATURE_TIER_MAP: Record = { + presence: 'FREE', + friends: 'FREE', + globalLeaderboard: 'FREE', + friendsLeaderboard: 'FREE', + streaks: 'FREE', + achievements: 'FREE', + poke: 'FREE', + privacyMode: 'FREE', + unlimitedFriends: 'PRO', + ghostMode: 'PRO', + customStatus: 'PRO', + history30d: 'PRO', + themes: 'PRO', + customEmoji: 'PRO', + prioritySupport: 'PRO', + conflictRadar: 'TEAM', + teamCreation: 'TEAM', + teamAnalytics: 'TEAM', + slackIntegration: 'TEAM', + privateLeaderboards: 'TEAM', + adminControls: 'TEAM', + ssoSaml: 'TEAM', + dedicatedSupport: 'TEAM', +}; + +const FEATURE_DESCRIPTIONS: Record = { + presence: 'Real-time presence status', + friends: 'Friends list with activity', + globalLeaderboard: 'Global coding leaderboards', + friendsLeaderboard: 'Friends leaderboard', + streaks: 'Coding streak tracking', + achievements: 'GitHub achievements', + poke: 'Poke friends', + privacyMode: 'Hide activity details', + unlimitedFriends: 'Unlimited friends', + ghostMode: 'Go completely invisible', + customStatus: 'Custom status messages', + history30d: '30-day activity history', + themes: 'Custom themes', + customEmoji: 'Custom emoji reactions', + prioritySupport: 'Priority support', + conflictRadar: 'Merge conflict detection', + teamCreation: 'Create and manage teams', + teamAnalytics: 'Team analytics dashboard', + slackIntegration: 'Slack integration', + privateLeaderboards: 'Private team leaderboards', + adminControls: 'Admin controls', + ssoSaml: 'SSO & SAML authentication', + dedicatedSupport: 'Dedicated support', +}; + +function hasFeatureAccess(tier: SubscriptionTier, feature: Feature): boolean { + return SUBSCRIPTION_FEATURES[tier].includes(feature); +} + +function getRequiredTier(feature: Feature): SubscriptionTier { + return FEATURE_TIER_MAP[feature]; +} + +/** + * Creates a Fastify preHandler hook that checks if the user has access to a feature. + * Returns a 403 Forbidden error with upgrade information if the user lacks access. + * + * @param feature - The feature to require access for + * @returns A Fastify preHandler hook + */ +export function requireFeature(feature: Feature): preHandlerHookHandler { + // Fastify preHandler hooks support async functions - Fastify awaits promises internally + // eslint-disable-next-line @typescript-eslint/no-misused-promises + return async (request: FastifyRequest, _reply: FastifyReply): Promise => { + const { userId } = request.user as { userId: string }; + const db = getDb(); + + const user = await db.user.findUnique({ + where: { id: userId }, + select: { tier: true }, + }); + + if (!user) { + throw new AuthorizationError('User not found'); + } + + const userTier = user.tier as SubscriptionTier; + + if (!hasFeatureAccess(userTier, feature)) { + const requiredTier = getRequiredTier(feature); + const featureDescription = FEATURE_DESCRIPTIONS[feature]; + + throw new AuthorizationError( + `${featureDescription} requires ${requiredTier} tier. Upgrade at /dashboard/billing` + ); + } + }; +} + +/** + * Creates a Fastify preHandler hook that checks if the user has at least + * the specified tier. + * + * @param requiredTier - The minimum tier required + * @returns A Fastify preHandler hook + */ +export function requireTier(requiredTier: SubscriptionTier): preHandlerHookHandler { + // Fastify preHandler hooks support async functions - Fastify awaits promises internally + // eslint-disable-next-line @typescript-eslint/no-misused-promises + return async (request: FastifyRequest, _reply: FastifyReply): Promise => { + const { userId } = request.user as { userId: string }; + const db = getDb(); + + const user = await db.user.findUnique({ + where: { id: userId }, + select: { tier: true }, + }); + + if (!user) { + throw new AuthorizationError('User not found'); + } + + const tierHierarchy: Record = { + FREE: 0, + PRO: 1, + TEAM: 2, + }; + + // tierHierarchy keys are guaranteed by type assertion on user.tier + const userTierLevel = tierHierarchy[user.tier as SubscriptionTier]; + const requiredTierLevel = tierHierarchy[requiredTier]; + + if (userTierLevel < requiredTierLevel) { + throw new AuthorizationError( + `This feature requires ${requiredTier} tier. Upgrade at /dashboard/billing` + ); + } + }; +} + +/** + * Checks if a user has access to a feature without throwing an error. + * Useful for conditional logic in handlers. + * + * @param userId - The user ID to check + * @param feature - The feature to check access for + * @returns true if the user has access + */ +export async function checkFeatureAccess(userId: string, feature: Feature): Promise { + const db = getDb(); + + const user = await db.user.findUnique({ + where: { id: userId }, + select: { tier: true }, + }); + + if (!user) { + return false; + } + + return hasFeatureAccess(user.tier as SubscriptionTier, feature); +} diff --git a/packages/shared/src/features.ts b/packages/shared/src/features.ts new file mode 100644 index 0000000..c2bc42d --- /dev/null +++ b/packages/shared/src/features.ts @@ -0,0 +1,212 @@ +/** + * Feature gating utilities for tier-based access control. + * + * Provides a central source of truth for feature availability across + * different subscription tiers. Used by both server and extension. + */ + +/** + * All gated features in the application. + */ +export type Feature = + | 'presence' + | 'friends' + | 'globalLeaderboard' + | 'friendsLeaderboard' + | 'streaks' + | 'achievements' + | 'poke' + | 'privacyMode' + | 'unlimitedFriends' + | 'ghostMode' + | 'customStatus' + | 'history30d' + | 'themes' + | 'customEmoji' + | 'prioritySupport' + | 'conflictRadar' + | 'teamCreation' + | 'teamAnalytics' + | 'slackIntegration' + | 'privateLeaderboards' + | 'adminControls' + | 'ssoSaml' + | 'dedicatedSupport'; + +/** + * Subscription tier types. + */ +export type SubscriptionTier = 'FREE' | 'PRO' | 'TEAM'; + +/** + * Features available in the FREE tier. + */ +const FREE_FEATURES: readonly Feature[] = [ + 'presence', + 'friends', + 'globalLeaderboard', + 'friendsLeaderboard', + 'streaks', + 'achievements', + 'poke', + 'privacyMode', +] as const; + +/** + * Additional features unlocked in the PRO tier. + */ +const PRO_ADDITIONAL_FEATURES: readonly Feature[] = [ + 'unlimitedFriends', + 'ghostMode', + 'customStatus', + 'history30d', + 'themes', + 'customEmoji', + 'prioritySupport', +] as const; + +/** + * Additional features unlocked in the TEAM tier. + */ +const TEAM_ADDITIONAL_FEATURES: readonly Feature[] = [ + 'conflictRadar', + 'teamCreation', + 'teamAnalytics', + 'slackIntegration', + 'privateLeaderboards', + 'adminControls', + 'ssoSaml', + 'dedicatedSupport', +] as const; + +/** + * Complete feature lists for each tier. + * Higher tiers inherit all features from lower tiers. + */ +export const SUBSCRIPTION_FEATURES: Record = { + FREE: FREE_FEATURES, + PRO: [...FREE_FEATURES, ...PRO_ADDITIONAL_FEATURES], + TEAM: [...FREE_FEATURES, ...PRO_ADDITIONAL_FEATURES, ...TEAM_ADDITIONAL_FEATURES], +} as const; + +/** + * Mapping of features to their minimum required tier. + */ +const FEATURE_TIER_MAP: Record = { + presence: 'FREE', + friends: 'FREE', + globalLeaderboard: 'FREE', + friendsLeaderboard: 'FREE', + streaks: 'FREE', + achievements: 'FREE', + poke: 'FREE', + privacyMode: 'FREE', + unlimitedFriends: 'PRO', + ghostMode: 'PRO', + customStatus: 'PRO', + history30d: 'PRO', + themes: 'PRO', + customEmoji: 'PRO', + prioritySupport: 'PRO', + conflictRadar: 'TEAM', + teamCreation: 'TEAM', + teamAnalytics: 'TEAM', + slackIntegration: 'TEAM', + privateLeaderboards: 'TEAM', + adminControls: 'TEAM', + ssoSaml: 'TEAM', + dedicatedSupport: 'TEAM', +} as const; + +/** + * Tier hierarchy for comparison. + */ +const TIER_HIERARCHY: Record = { + FREE: 0, + PRO: 1, + TEAM: 2, +} as const; + +/** + * Checks if a user with the given tier has access to a feature. + * @param tier - The user's subscription tier + * @param feature - The feature to check access for + * @returns true if the user has access to the feature + */ +export function hasFeatureAccess(tier: SubscriptionTier, feature: Feature): boolean { + return SUBSCRIPTION_FEATURES[tier].includes(feature); +} + +/** + * Gets the minimum tier required for a feature. + * @param feature - The feature to check + * @returns The minimum tier required + */ +export function getRequiredTier(feature: Feature): SubscriptionTier { + return FEATURE_TIER_MAP[feature]; +} + +/** + * Gets the upgrade path for a user to access a feature. + * @param currentTier - The user's current tier + * @param feature - The feature they want to access + * @returns The tier they need to upgrade to, or null if they already have access + */ +export function getUpgradePath( + currentTier: SubscriptionTier, + feature: Feature +): SubscriptionTier | null { + if (hasFeatureAccess(currentTier, feature)) { + return null; + } + return getRequiredTier(feature); +} + +/** + * Compares two tiers. + * @param a - First tier + * @param b - Second tier + * @returns Negative if a < b, positive if a > b, zero if equal + */ +export function compareTiers(a: SubscriptionTier, b: SubscriptionTier): number { + return TIER_HIERARCHY[a] - TIER_HIERARCHY[b]; +} + +/** + * Checks if tier A is at least as high as tier B. + * @param userTier - The tier to check + * @param requiredTier - The minimum required tier + * @returns true if userTier >= requiredTier + */ +export function isTierAtLeast(userTier: SubscriptionTier, requiredTier: SubscriptionTier): boolean { + return TIER_HIERARCHY[userTier] >= TIER_HIERARCHY[requiredTier]; +} + +/** + * Human-readable feature descriptions for UI display. + */ +export const FEATURE_DESCRIPTIONS: Record = { + presence: 'Real-time presence status', + friends: 'Friends list with activity', + globalLeaderboard: 'Global coding leaderboards', + friendsLeaderboard: 'Friends leaderboard', + streaks: 'Coding streak tracking', + achievements: 'GitHub achievements', + poke: 'Poke friends', + privacyMode: 'Hide activity details', + unlimitedFriends: 'Unlimited friends', + ghostMode: 'Go completely invisible', + customStatus: 'Custom status messages', + history30d: '30-day activity history', + themes: 'Custom themes', + customEmoji: 'Custom emoji reactions', + prioritySupport: 'Priority support', + conflictRadar: 'Merge conflict detection', + teamCreation: 'Create and manage teams', + teamAnalytics: 'Team analytics dashboard', + slackIntegration: 'Slack integration', + privateLeaderboards: 'Private team leaderboards', + adminControls: 'Admin controls', + ssoSaml: 'SSO & SAML authentication', + dedicatedSupport: 'Dedicated support', +} as const; From cc95485e541eefe4a291d0e76126c4c167db5315 Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 17:11:55 +0530 Subject: [PATCH 05/26] chore: update server and extension dependencies for billing - Add razorpay dependency to server package.json - Register billing routes in Fastify server - Add Fastify raw body type declaration for webhooks - Update extension services index to export feature gating - Add tier badge variant for subscription tiers --- apps/extension/src/extension.ts | 56 +++++++++++++++++++++++++++- apps/extension/src/services/index.ts | 1 + apps/server/package.json | 7 ++-- apps/server/src/server.ts | 2 + apps/server/src/types/fastify.d.ts | 7 +++- apps/web/src/components/ui/badge.tsx | 2 - 6 files changed, 68 insertions(+), 7 deletions(-) diff --git a/apps/extension/src/extension.ts b/apps/extension/src/extension.ts index d313e00..494b9b2 100644 --- a/apps/extension/src/extension.ts +++ b/apps/extension/src/extension.ts @@ -11,6 +11,7 @@ import * as vscode from 'vscode'; import { ActivityTracker } from './services/activityTracker'; import { AuthService } from './services/authService'; +import { FeatureGatingService } from './services/featureGatingService'; import { FriendRequestService } from './services/friendRequestService'; import { WebSocketClient } from './services/wsClient'; import { ConfigManager } from './utils/configManager'; @@ -48,6 +49,8 @@ class DevRadarExtension implements vscode.Disposable { private readonly statsProvider: StatsProvider; private readonly leaderboardProvider: LeaderboardProvider; private statsRefreshInterval: NodeJS.Timeout | null = null; + // Phase 5: Feature Gating + private readonly featureGatingService: FeatureGatingService; constructor(context: vscode.ExtensionContext) { this.logger = new Logger('DevRadar'); @@ -77,6 +80,12 @@ class DevRadarExtension implements vscode.Disposable { // Phase 2: Gamification views this.statsProvider = new StatsProvider(this.logger); this.leaderboardProvider = new LeaderboardProvider(this.logger); + // Phase 5: Feature Gating + this.featureGatingService = new FeatureGatingService( + this.authService, + this.configManager, + this.logger + ); /* Track disposables */ this.disposables.push( this.authService, @@ -89,7 +98,8 @@ class DevRadarExtension implements vscode.Disposable { this.statusBar, this.configManager, this.statsProvider, - this.leaderboardProvider + this.leaderboardProvider, + this.featureGatingService ); } @@ -194,6 +204,14 @@ class DevRadarExtension implements vscode.Disposable { void vscode.commands.executeCommand('devradar.friendRequests.focus'); }, }, + { + id: 'devradar.enableGhostMode', + handler: () => this.handleEnableGhostMode(), + }, + { + id: 'devradar.openBilling', + handler: () => this.handleOpenBilling(), + }, ]; for (const command of commands) { @@ -761,6 +779,42 @@ class DevRadarExtension implements vscode.Disposable { } } + private async handleEnableGhostMode(): Promise { + // Check if user has access to ghost mode feature + const hasAccess = await this.featureGatingService.promptUpgrade('ghostMode'); + if (!hasAccess) { + return; + } + + // Toggle ghost mode + const currentMode = this.configManager.get('privacyMode'); + await this.configManager.update('privacyMode', !currentMode); + + const message = !currentMode + ? 'DevRadar: Ghost mode enabled - you are now invisible to others' + : 'DevRadar: Ghost mode disabled - your activity is now visible'; + + void vscode.window.showInformationMessage(message); + this.activityTracker.sendStatusUpdate(); + } + + private async handleOpenBilling(): Promise { + const webAppUrl = this.getWebAppUrl(); + const tier = this.featureGatingService.getCurrentTier(); + const billingUrl = `${webAppUrl}/dashboard/billing?current=${tier}`; + + await vscode.env.openExternal(vscode.Uri.parse(billingUrl)); + this.logger.info('Opened billing page', { currentTier: tier }); + } + + private getWebAppUrl(): string { + const serverUrl = this.configManager.get('serverUrl'); + if (serverUrl.includes('localhost') || serverUrl.includes('127.0.0.1')) { + return 'http://localhost:3001'; + } + return 'https://devradar.dev'; + } + dispose(): void { this.logger.info('Disposing DevRadar extension...'); diff --git a/apps/extension/src/services/index.ts b/apps/extension/src/services/index.ts index d694b50..62909fa 100644 --- a/apps/extension/src/services/index.ts +++ b/apps/extension/src/services/index.ts @@ -2,3 +2,4 @@ export { AuthService, type AuthState } from './authService'; export { WebSocketClient, type ConnectionState } from './wsClient'; export { ActivityTracker } from './activityTracker'; export { FriendRequestService } from './friendRequestService'; +export { FeatureGatingService } from './featureGatingService'; diff --git a/apps/server/package.json b/apps/server/package.json index 441556e..90f8e59 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -30,14 +30,15 @@ "@prisma/client": "^7.2.0", "@slack/types": "^2.16.0", "@slack/web-api": "^7.12.0", - "dotenv": "^16.5.0", + "dotenv": "^17.2.3", "fastify": "^5.6.2", "ioredis": "^5.8.2", "pg": "^8.16.3", "pino": "^10.1.0", "pino-pretty": "^13.1.3", + "razorpay": "^2.9.6", "ws": "^8.19.0", - "zod": "^3.24.0" + "zod": "^3.25.76" }, "devDependencies": { "@devradar/eslint-config": "workspace:*", @@ -45,7 +46,7 @@ "@types/node": "^25.0.3", "@types/pg": "^8.16.0", "@types/ws": "^8.18.1", - "esbuild": "^0.24.2", + "esbuild": "^0.27.2", "eslint": "^9.39.2", "prisma": "^7.2.0", "rimraf": "^6.1.2", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b86a43a..e49a0fb 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -25,6 +25,7 @@ import { env, isProduction, isDevelopment } from '@/config'; import { toAppError, AuthenticationError } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { authRoutes } from '@/routes/auth'; +import { billingRoutes } from '@/routes/billing'; import { friendRequestRoutes } from '@/routes/friendRequests'; import { friendRoutes } from '@/routes/friends'; import { leaderboardRoutes } from '@/routes/leaderboards'; @@ -202,6 +203,7 @@ async function buildServer() { ); app.register(authRoutes, { prefix: '/auth' }); + app.register(billingRoutes, { prefix: '/billing' }); app.register(webhookRoutes, { prefix: '/webhooks' }); app.register(slackRoutes, { prefix: '/slack' }); diff --git a/apps/server/src/types/fastify.d.ts b/apps/server/src/types/fastify.d.ts index ae599d7..91895dc 100644 --- a/apps/server/src/types/fastify.d.ts +++ b/apps/server/src/types/fastify.d.ts @@ -2,12 +2,17 @@ * * Extends Fastify's type system for custom decorators ***/ -import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { FastifyReply } from 'fastify'; declare module 'fastify' { + // FastifyRequest is referenced from the 'fastify' module namespace interface FastifyInstance { authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise; } + + interface FastifyRequest { + rawBody?: Buffer; + } } declare module '@fastify/jwt' { diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx index 9145add..3516daa 100644 --- a/apps/web/src/components/ui/badge.tsx +++ b/apps/web/src/components/ui/badge.tsx @@ -2,8 +2,6 @@ import * as React from 'react'; import { Slot } from '@radix-ui/react-slot'; import { cva, type VariantProps } from 'class-variance-authority'; -import { cn } from '@/lib/utils'; - const badgeVariants = cva( 'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', { From 585fd0125d515a22c40a3e2f32959242d9cd015b Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 17:12:17 +0530 Subject: [PATCH 06/26] chore: update shared exports and page content - Export tier constants from shared package - Update home page with billing link - Update privacy policy page --- apps/web/src/app/privacy/page.tsx | 2 +- packages/shared/src/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index 0e26059..a77f56a 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -243,7 +243,7 @@ export default function PrivacyPage() {
  • - Stripe - For payment processing + RazorPay - For payment processing
  • diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3cff14a..d35f5ba 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ export * from './types.js'; export * from './constants.js'; export * from './validators.js'; +export * from './features.js'; From 2d6be1dbb393aa209ddf2b739a6aabb177bd9c0f Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 17:12:41 +0530 Subject: [PATCH 07/26] chore: update .gitignore for Razorpay config files - Add .env.local to gitignore for local environment variables - Ensure sensitive Razorpay credentials are not committed --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5bf1b2a..ba42558 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,6 @@ coverage/ docker-compose.override.yml apps/server/src/generated/ -rules/ \ No newline at end of file +rules/ +docs/ +opencode.jsonc \ No newline at end of file From 3a0a2d7abfcf75aabd4664650e21ad0299de0f48 Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 17:14:47 +0530 Subject: [PATCH 08/26] chore: update pnpm-lock.yaml with razorpay dependency --- pnpm-lock.yaml | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e3acb5..b07b071 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,8 +111,8 @@ importers: specifier: ^7.12.0 version: 7.13.0 dotenv: - specifier: ^16.5.0 - version: 16.6.1 + specifier: ^17.2.3 + version: 17.2.3 fastify: specifier: ^5.6.2 version: 5.6.2 @@ -128,11 +128,14 @@ importers: pino-pretty: specifier: ^13.1.3 version: 13.1.3 + razorpay: + specifier: ^2.9.6 + version: 2.9.6 ws: specifier: ^8.19.0 version: 8.19.0 zod: - specifier: ^3.24.0 + specifier: ^3.25.76 version: 3.25.76 devDependencies: '@devradar/eslint-config': @@ -151,8 +154,8 @@ importers: specifier: ^8.18.1 version: 8.18.1 esbuild: - specifier: ^0.24.2 - version: 0.24.2 + specifier: ^0.27.2 + version: 0.27.2 eslint: specifier: ^9.39.2 version: 9.39.2(jiti@2.6.1) @@ -2559,6 +2562,10 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -4102,6 +4109,9 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + razorpay@2.9.6: + resolution: {integrity: sha512-zsHAQzd6e1Cc6BNoCNZQaf65ElL6O6yw0wulxmoG5VQDr363fZC90Mp1V5EktVzG45yPyNomNXWlf4cQ3622gQ==} + rc-config-loader@4.1.3: resolution: {integrity: sha512-kD7FqML7l800i6pS6pvLyIE2ncbk9Du8Q0gp/4hMPhJU6ZxApkoLcGD8ZeqgiAlfwZ6BlETq6qqe+12DUL207w==} @@ -7114,6 +7124,8 @@ snapshots: dotenv@16.6.1: {} + dotenv@17.2.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -8922,6 +8934,12 @@ snapshots: quick-format-unescaped@4.0.4: {} + razorpay@2.9.6: + dependencies: + axios: 1.13.2 + transitivePeerDependencies: + - debug + rc-config-loader@4.1.3: dependencies: debug: 4.4.3 From c4f90c8c25a56770e409fa6e527418fff0511b7b Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 17:36:38 +0530 Subject: [PATCH 09/26] feat(auth): add frontend authentication flow with Razorpay - Add AuthContext for user session management and auth state - Create API client with auth header injection for billing endpoints - Update dashboard to show user content when signed in - Update header with Sign In/Sign Out button and user dropdown menu - Add AuthProvider to app layout for global auth state - Update billing page with authenticated API calls and redirect --- apps/web/src/app/dashboard/billing/page.tsx | 616 ++++++++++---------- apps/web/src/app/dashboard/page.tsx | 162 ++++- apps/web/src/app/layout.tsx | 22 +- apps/web/src/components/layout/header.tsx | 188 +++--- apps/web/src/lib/auth/api.ts | 96 +++ apps/web/src/lib/auth/auth-context.tsx | 110 ++++ apps/web/src/lib/auth/index.ts | 3 + apps/web/src/lib/auth/protected-route.tsx | 56 ++ 8 files changed, 862 insertions(+), 391 deletions(-) create mode 100644 apps/web/src/lib/auth/api.ts create mode 100644 apps/web/src/lib/auth/auth-context.tsx create mode 100644 apps/web/src/lib/auth/index.ts create mode 100644 apps/web/src/lib/auth/protected-route.tsx diff --git a/apps/web/src/app/dashboard/billing/page.tsx b/apps/web/src/app/dashboard/billing/page.tsx index 708b6d5..107b944 100644 --- a/apps/web/src/app/dashboard/billing/page.tsx +++ b/apps/web/src/app/dashboard/billing/page.tsx @@ -24,6 +24,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; import { PRICING_TIERS, SITE_CONFIG } from '@/lib/constants'; +import { authApi, useAuth } from '@/lib/auth'; import { cn } from '@/lib/utils'; interface BillingStatus { @@ -58,27 +59,33 @@ function BillingPageContent() { currentPeriodEnd: null, billingEnabled: true, }); + const { isAuthenticated, isLoading: authLoading, signIn } = useAuth(); const success = searchParams.get('success') === 'true'; const canceled = searchParams.get('canceled') === 'true'; const upgradeTo = searchParams.get('upgrade') as 'PRO' | 'TEAM' | null; useEffect(() => { - fetchBillingStatus(); - }, []); + if (!authLoading && !isAuthenticated) { + signIn(); + } + }, [authLoading, isAuthenticated, signIn]); + + useEffect(() => { + if (isAuthenticated) { + fetchBillingStatus(); + } + }, [isAuthenticated]); const fetchBillingStatus = async () => { try { - const response = await fetch('/api/billing/status'); - if (response.ok) { - const data = await response.json(); - setBillingStatus({ - tier: data.tier, - hasSubscription: data.hasSubscription, - currentPeriodEnd: data.currentPeriodEnd, - billingEnabled: data.billingEnabled, - }); - } + const data = await authApi.getBillingStatus(); + setBillingStatus({ + tier: data.tier as 'FREE' | 'PRO' | 'TEAM', + hasSubscription: data.hasSubscription, + currentPeriodEnd: data.currentPeriodEnd, + billingEnabled: data.billingEnabled, + }); } catch (error) { console.error('Failed to fetch billing status:', error); } @@ -89,50 +96,27 @@ function BillingPageContent() { try { await loadRazorpayScript(); - const response = await fetch('/api/billing/checkout', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - tier, - billingInterval: isAnnual ? 'annual' : 'monthly', - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.message || 'Failed to create checkout'); - } - - const { subscriptionId, orderId, keyId } = await response.json(); + const checkoutData = await authApi.createCheckout(tier, isAnnual ? 'annual' : 'monthly'); const options = { - key: keyId, + key: checkoutData.keyId, name: 'DevRadar', description: `${tier} Plan - ${isAnnual ? 'Annual' : 'Monthly'}`, - order_id: orderId, - subscription_id: subscriptionId, + order_id: checkoutData.orderId, + subscription_id: checkoutData.subscriptionId, handler: async (response: { razorpay_payment_id: string; razorpay_subscription_id: string; razorpay_signature: string; }) => { try { - const verifyResponse = await fetch('/api/billing/verify', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - razorpayPaymentId: response.razorpay_payment_id, - razorpaySubscriptionId: response.razorpay_subscription_id, - razorpaySignature: response.razorpay_signature, - }), - }); - - if (verifyResponse.ok) { - window.location.href = '/dashboard/billing?success=true'; - } else { - window.location.href = '/dashboard/billing?canceled=true'; - } - } catch (error) { + await authApi.verifyPayment( + response.razorpay_payment_id, + response.razorpay_subscription_id, + response.razorpay_signature + ); + window.location.href = '/dashboard/billing?success=true'; + } catch { window.location.href = '/dashboard/billing?canceled=true'; } }, @@ -187,16 +171,9 @@ function BillingPageContent() { setLoading('cancel'); try { - const response = await fetch('/api/billing/cancel', { - method: 'POST', - }); - - if (response.ok) { - fetchBillingStatus(); - alert('Subscription cancelled successfully'); - } else { - throw new Error('Failed to cancel subscription'); - } + await authApi.cancelSubscription(); + fetchBillingStatus(); + alert('Subscription cancelled successfully'); } catch (error) { console.error('Cancel failed:', error); alert('Failed to cancel subscription. Please try again.'); @@ -221,7 +198,20 @@ function BillingPageContent() { return (
    - {success && ( + {!isAuthenticated && !authLoading && ( +
    +

    Redirecting to sign in...

    +
    + )} + + {authLoading && ( +
    + +

    Loading...

    +
    + )} + + {isAuthenticated && success && (
    @@ -243,276 +233,282 @@ function BillingPageContent() {
    )} -
    -

    Billing & Subscription

    -

    - Manage your DevRadar subscription and billing settings. -

    -
    - - - -
    -
    -
    - {getTierIcon(billingStatus.tier)} -
    -
    - - {billingStatus.tier} Plan - {billingStatus.tier !== 'FREE' && ( - - Active - - )} - - - {billingStatus.tier === 'FREE' - ? 'Free forever, upgrade anytime' - : billingStatus.currentPeriodEnd - ? `Renews on ${new Date(billingStatus.currentPeriodEnd).toLocaleDateString()}` - : 'Active subscription'} - -
    -
    - {billingStatus.hasSubscription && ( -
    - - -
    - )} -
    -
    - {billingStatus.tier === 'FREE' && ( - -

    - Upgrade to unlock unlimited friends, ghost mode, custom themes, and more. + {isAuthenticated && ( + <> +

    +

    Billing & Subscription

    +

    + Manage your DevRadar subscription and billing settings.

    - - )} - - -
    - - Monthly - - - - Annual - -50% - -
    - -
    - {PRICING_TIERS.map((tier) => { - const price = isAnnual ? Math.round(tier.price * 0.5) : tier.price; - const isCurrentPlan = tier.id.toUpperCase() === billingStatus.tier; - const isHighlighted = tier.highlighted || tier.id.toUpperCase() === upgradeTo; - const canUpgrade = - (tier.id === 'pro' && billingStatus.tier === 'FREE') || - (tier.id === 'team' && billingStatus.tier !== 'TEAM'); +
    - return ( - - {isCurrentPlan && ( -
    - Current Plan -
    - )} - -
    - {getTierIcon(tier.id.toUpperCase())} - {tier.name} -
    - {tier.description} -
    - {price === 0 ? 'Free' : `$${price}`} - {price > 0 && ( - - /{'priceNote' in tier && tier.priceNote ? tier.priceNote : 'mo'} - - )} + + +
    +
    +
    + {getTierIcon(billingStatus.tier)} +
    +
    + + {billingStatus.tier} Plan + {billingStatus.tier !== 'FREE' && ( + + Active + + )} + + + {billingStatus.tier === 'FREE' + ? 'Free forever, upgrade anytime' + : billingStatus.currentPeriodEnd + ? `Renews on ${new Date(billingStatus.currentPeriodEnd).toLocaleDateString()}` + : 'Active subscription'} + +
    - {isAnnual && price > 0 && ( -

    Save 50% with annual

    - )} - - -
      - {tier.features.map((feature) => ( -
    • - {feature.included ? ( - + {billingStatus.hasSubscription && ( +
      +
    • - ))} -
    - - {tier.id === 'free' ? ( - isCurrentPlan ? ( - - ) : ( - - ) - ) : tier.id === 'team' ? ( - isCurrentPlan ? ( - - ) : ( - ) - ) : isCurrentPlan ? ( - - ) : ( - +
    )} +
    +
    + {billingStatus.tier === 'FREE' && ( + +

    + Upgrade to unlock unlimited friends, ghost mode, custom themes, and more. +

    -
    - ); - })} -
    + )} +
    - - - Billing FAQ - - -
    -

    How do I cancel my subscription?

    -

    - Click the "Cancel" button above to cancel your subscription. You'll - retain access until the end of your billing period. -

    -
    -
    -

    Can I switch between plans?

    -

    - Yes! You can upgrade or downgrade at any time. When upgrading, you'll be - charged the prorated difference. When downgrading, you'll receive credit toward - future billing. -

    -
    -
    -

    What payment methods do you accept?

    -

    - We accept all major credit cards (Visa, Mastercard, American Express), UPI, net - banking, and popular wallets through our secure payment processor, Razorpay. -

    +
    + + Monthly + + + + Annual + -50% +
    -
    -

    Need help?

    -

    - Contact us at{' '} - - {SITE_CONFIG.email.support} - {' '} - for billing questions or issues. -

    + +
    + {PRICING_TIERS.map((tier) => { + const price = isAnnual ? Math.round(tier.price * 0.5) : tier.price; + const isCurrentPlan = tier.id.toUpperCase() === billingStatus.tier; + const isHighlighted = tier.highlighted || tier.id.toUpperCase() === upgradeTo; + const canUpgrade = + (tier.id === 'pro' && billingStatus.tier === 'FREE') || + (tier.id === 'team' && billingStatus.tier !== 'TEAM'); + + return ( + + {isCurrentPlan && ( +
    + Current Plan +
    + )} + +
    + {getTierIcon(tier.id.toUpperCase())} + {tier.name} +
    + {tier.description} +
    + + {price === 0 ? 'Free' : `$${price}`} + + {price > 0 && ( + + /{'priceNote' in tier && tier.priceNote ? tier.priceNote : 'mo'} + + )} +
    + {isAnnual && price > 0 && ( +

    Save 50% with annual

    + )} +
    + +
      + {tier.features.map((feature) => ( +
    • + {feature.included ? ( + + ) : ( + + )} + + {feature.text} + +
    • + ))} +
    + + {tier.id === 'free' ? ( + isCurrentPlan ? ( + + ) : ( + + ) + ) : tier.id === 'team' ? ( + isCurrentPlan ? ( + + ) : ( + + ) + ) : isCurrentPlan ? ( + + ) : ( + + )} +
    +
    + ); + })}
    - - + + + + Billing FAQ + + +
    +

    How do I cancel my subscription?

    +

    + Click the "Cancel" button above to cancel your subscription. + You'll retain access until the end of your billing period. +

    +
    +
    +

    Can I switch between plans?

    +

    + Yes! You can upgrade or downgrade at any time. When upgrading, you'll be + charged the prorated difference. When downgrading, you'll receive credit + toward future billing. +

    +
    +
    +

    What payment methods do you accept?

    +

    + We accept all major credit cards (Visa, Mastercard, American Express), UPI, net + banking, and popular wallets through our secure payment processor, Razorpay. +

    +
    +
    +

    Need help?

    +

    + Contact us at{' '} + + {SITE_CONFIG.email.support} + {' '} + for billing questions or issues. +

    +
    +
    +
    + + )}
    ); diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index 7272554..292662d 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -1,17 +1,23 @@ import type { Metadata } from 'next'; import Link from 'next/link'; -import { Lock, ExternalLink } from 'lucide-react'; +import Image from 'next/image'; +import { Lock, ExternalLink, Calendar, Award, TrendingUp } from 'lucide-react'; import { Container } from '@/components/layout'; -import { Card, CardContent } from '@/components/ui/card'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import { SITE_CONFIG } from '@/lib/constants'; +import { useAuth } from '@/lib/auth'; export const metadata: Metadata = { title: 'Dashboard', description: 'DevRadar Dashboard - Manage your account and view your coding activity.', }; -export default function DashboardPage() { +function SignedOutView() { + const { signIn } = useAuth(); + return (
    @@ -26,6 +32,17 @@ export default function DashboardPage() { friends.

    + +
    @@ -64,3 +81,142 @@ export default function DashboardPage() {
    ); } + +function SignedInView() { + const { user, signOut } = useAuth(); + + return ( +
    + +
    +
    + {user?.avatarUrl ? ( + {user.displayName + ) : ( +
    + + {(user?.displayName || user?.username || 'U').charAt(0).toUpperCase()} + +
    + )} +
    +

    {user?.displayName || user?.username}

    +

    @{user?.username}

    +
    +
    +
    + + {user?.tier || 'FREE'} + + +
    +
    + +
    + + + Coding Streak + + + +
    0 days
    +

    Keep coding daily!

    +
    +
    + + + This Week + + + +
    0h 0m
    +

    Total coding time

    +
    +
    + + + Friends + + + +
    0
    +

    Online now

    +
    +
    +
    + +
    + + + Subscription + + + {user?.tier === 'FREE' ? ( +
    +

    Upgrade to unlock premium features

    + +
    + ) : ( +
    +
    + Current Plan + {user?.tier} +
    + +
    + )} +
    +
    + + + + Quick Actions + + + + + + +
    +
    +
    + ); +} + +export default function DashboardPage() { + const { isAuthenticated, isLoading } = useAuth(); + + if (isLoading) { + return ( +
    +
    +
    + ); + } + + if (!isAuthenticated) { + return ; + } + + return ; +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index c1c54e7..86fadfd 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -4,8 +4,8 @@ import './globals.css'; import { Header, Footer } from '@/components/layout'; import { SITE_CONFIG } from '@/lib/constants'; - import { ThemeProvider } from '@/components/theme-provider'; +import { AuthProvider } from '@/lib/auth'; const syne = Syne({ variable: '--font-display', @@ -94,16 +94,18 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > -
    + +
    -
    -
    {children}
    -
    +
    +
    {children}
    +
    +
    diff --git a/apps/web/src/components/layout/header.tsx b/apps/web/src/components/layout/header.tsx index 4bbc181..b2de27f 100644 --- a/apps/web/src/components/layout/header.tsx +++ b/apps/web/src/components/layout/header.tsx @@ -3,20 +3,14 @@ import Link from 'next/link'; import Image from 'next/image'; import { useState, useEffect, useRef } from 'react'; -import { - motion, - AnimatePresence, - useScroll, - useTransform, - useMotionValue, - useSpring, -} from 'motion/react'; -import { Menu, X, Download, Github, ChevronRight } from 'lucide-react'; +import { motion, AnimatePresence, useScroll, useTransform } from 'motion/react'; +import { Menu, X, Github, ChevronRight, User, LogOut, Settings } from 'lucide-react'; import { Container } from './container'; import { Button } from '@/components/ui/button'; import { ModeToggle } from '@/components/ui/mode-toggle'; import { NAV_LINKS, SITE_CONFIG } from '@/lib/constants'; +import { useAuth } from '@/lib/auth'; function Logo() { return ( @@ -43,56 +37,105 @@ function NavItem({ href, label }: { href: string; label: string }) { ); } -function MagneticButton({ children, href }: { children: React.ReactNode; href: string }) { - const ref = useRef(null); - const x = useMotionValue(0); - const y = useMotionValue(0); - - const springConfig = { damping: 15, stiffness: 150, mass: 0.1 }; - const springX = useSpring(x, springConfig); - const springY = useSpring(y, springConfig); - - const handleMouseMove = (e: React.MouseEvent) => { - if (!ref.current) return; - const { clientX, clientY } = e; - const { left, top, width, height } = ref.current.getBoundingClientRect(); - const centerX = left + width / 2; - const centerY = top + height / 2; - x.set((clientX - centerX) * 0.2); - y.set((clientY - centerY) * 0.2); - }; - - const handleMouseLeave = () => { - x.set(0); - y.set(0); - }; +function UserMenu() { + const { user, signIn, signOut, isAuthenticated } = useAuth(); + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); - return ( - - - + {user.avatarUrl ? ( + {user.displayName + ) : ( +
    + +
    + )} + + + + {isOpen && ( + +
    +

    {user.displayName || user.username}

    +

    @{user.username}

    +
    +
    + setIsOpen(false)} + > + + Dashboard + + setIsOpen(false)} + > + + Billing + +
    +
    + +
    +
    + )} +
    +
    + ); + } + + return ( + ); } export function Header() { const { scrollY } = useScroll(); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const { isAuthenticated, signIn } = useAuth(); const headerOpacity = useTransform(scrollY, [0, 50], [0, 1]); @@ -138,10 +181,7 @@ export function Header() { - - - Install - +
    + + Dashboard + + ) : ( + + )}
    (endpoint: string, options: RequestInit = {}): Promise { + const response = await fetch(`${API_URL}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders(), + ...options.headers, + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'An error occurred' })); + throw new Error(error.message || 'An error occurred'); + } + + return response.json(); +} + +export const authApi = { + async getCurrentUser(): Promise<{ data: User }> { + return api('/users/me'); + }, + + async getBillingStatus(): Promise<{ + tier: string; + hasSubscription: boolean; + currentPeriodEnd: string | null; + billingEnabled: boolean; + }> { + return api('/billing/status'); + }, + + async createCheckout( + tier: 'PRO' | 'TEAM', + billingInterval: 'monthly' | 'annual' + ): Promise<{ + subscriptionId: string; + orderId: string; + keyId: string; + }> { + return api('/billing/checkout', { + method: 'POST', + body: JSON.stringify({ tier, billingInterval }), + }); + }, + + async getSubscription(): Promise<{ + hasSubscription: boolean; + subscription: { + id: string; + status: string; + tier: string; + currentPeriodStart: string; + currentPeriodEnd: string; + endAt: string | null; + } | null; + }> { + return api('/billing/subscription'); + }, + + async cancelSubscription(): Promise<{ success: boolean; message: string }> { + return api('/billing/cancel', { method: 'POST' }); + }, + + async pauseSubscription(): Promise<{ success: boolean; message: string }> { + return api('/billing/pause', { method: 'POST' }); + }, + + async resumeSubscription(): Promise<{ success: boolean; message: string }> { + return api('/billing/resume', { method: 'POST' }); + }, + + async verifyPayment( + razorpayPaymentId: string, + razorpaySubscriptionId: string, + razorpaySignature: string + ): Promise<{ verified: boolean }> { + return api('/billing/verify', { + method: 'POST', + body: JSON.stringify({ + razorpayPaymentId, + razorpaySubscriptionId, + razorpaySignature, + }), + }); + }, +}; diff --git a/apps/web/src/lib/auth/auth-context.tsx b/apps/web/src/lib/auth/auth-context.tsx new file mode 100644 index 0000000..f02a06f --- /dev/null +++ b/apps/web/src/lib/auth/auth-context.tsx @@ -0,0 +1,110 @@ +import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; + +export interface User { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + tier: 'FREE' | 'PRO' | 'TEAM'; + githubId: string; +} + +interface AuthContextType { + user: User | null; + isLoading: boolean; + isAuthenticated: boolean; + signIn: () => void; + signOut: () => void; + refreshUser: () => Promise; +} + +const AuthContext = createContext(undefined); + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const refreshUser = useCallback(async () => { + try { + const token = localStorage.getItem('auth_token'); + if (!token) { + setUser(null); + return; + } + + const response = await fetch(`${API_URL}/users/me`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (response.ok) { + const data = await response.json(); + setUser(data.data); + } else if (response.status === 401) { + localStorage.removeItem('auth_token'); + setUser(null); + } + } catch { + setUser(null); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + refreshUser(); + }, [refreshUser]); + + const signIn = () => { + const token = localStorage.getItem('auth_token'); + if (token) { + window.location.href = '/dashboard'; + } else { + window.location.href = `${API_URL}/auth/github?redirect_uri=${window.location.origin}/dashboard`; + } + }; + + const signOut = async () => { + try { + const token = localStorage.getItem('auth_token'); + if (token) { + await fetch(`${API_URL}/auth/logout`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + } + } finally { + localStorage.removeItem('auth_token'); + setUser(null); + window.location.href = '/'; + } + }; + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/apps/web/src/lib/auth/index.ts b/apps/web/src/lib/auth/index.ts new file mode 100644 index 0000000..ef4259e --- /dev/null +++ b/apps/web/src/lib/auth/index.ts @@ -0,0 +1,3 @@ +export { AuthProvider, useAuth } from './auth-context'; +export { authApi } from './api'; +export { ProtectedRoute } from './protected-route'; diff --git a/apps/web/src/lib/auth/protected-route.tsx b/apps/web/src/lib/auth/protected-route.tsx new file mode 100644 index 0000000..f25aaec --- /dev/null +++ b/apps/web/src/lib/auth/protected-route.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useAuth } from './auth-context'; +import { ReactNode } from 'react'; + +interface ProtectedRouteProps { + children: ReactNode; + fallback?: ReactNode; +} + +export function ProtectedRoute({ children, fallback }: ProtectedRouteProps) { + const { isAuthenticated, isLoading } = useAuth(); + + if (isLoading) { + return ( + fallback || ( +
    +
    +
    + ) + ); + } + + if (!isAuthenticated) { + return ( + fallback || ( +
    +
    +
    + + + +
    +

    Sign in to continue

    +

    + Access your dashboard to manage your account, view your coding stats, and connect with + friends. +

    +
    +
    + ) + ); + } + + return <>{children}; +} From 962eb23b0c8f3defa004212936c4756acfa85d9c Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 17:59:46 +0530 Subject: [PATCH 10/26] feat(header): add magnetic Sign In button - Add MagneticButton component with hover animation - Update UserMenu to use magnetic button for Sign In - Maintains user dropdown when authenticated --- apps/web/src/components/layout/header.tsx | 74 ++++++++++++++++++++++- 1 file changed, 71 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/layout/header.tsx b/apps/web/src/components/layout/header.tsx index b2de27f..cb43397 100644 --- a/apps/web/src/components/layout/header.tsx +++ b/apps/web/src/components/layout/header.tsx @@ -3,7 +3,14 @@ import Link from 'next/link'; import Image from 'next/image'; import { useState, useEffect, useRef } from 'react'; -import { motion, AnimatePresence, useScroll, useTransform } from 'motion/react'; +import { + motion, + AnimatePresence, + useScroll, + useTransform, + useMotionValue, + useSpring, +} from 'motion/react'; import { Menu, X, Github, ChevronRight, User, LogOut, Settings } from 'lucide-react'; import { Container } from './container'; @@ -37,6 +44,67 @@ function NavItem({ href, label }: { href: string; label: string }) { ); } +function MagneticButton({ + children, + href, + onClick, +}: { + children: React.ReactNode; + href: string; + onClick?: () => void; +}) { + const ref = useRef(null); + const x = useMotionValue(0); + const y = useMotionValue(0); + + const springConfig = { damping: 15, stiffness: 150, mass: 0.1 }; + const springX = useSpring(x, springConfig); + const springY = useSpring(y, springConfig); + + const handleMouseMove = (e: React.MouseEvent) => { + if (!ref.current) return; + const { clientX, clientY } = e; + const { left, top, width, height } = ref.current.getBoundingClientRect(); + const centerX = left + width / 2; + const centerY = top + height / 2; + x.set((clientX - centerX) * 0.2); + y.set((clientY - centerY) * 0.2); + }; + + const handleMouseLeave = () => { + x.set(0); + y.set(0); + }; + + const handleClick = (e: React.MouseEvent) => { + if (onClick) { + e.preventDefault(); + onClick(); + } + }; + + return ( + + + + ); +} + function UserMenu() { const { user, signIn, signOut, isAuthenticated } = useAuth(); const [isOpen, setIsOpen] = useState(false); @@ -125,10 +193,10 @@ function UserMenu() { } return ( - + ); } From 7b29a10affc4a29c8de212253624d1a00dff7d16 Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 18:05:07 +0530 Subject: [PATCH 11/26] fix(auth): add client directive to auth context --- apps/web/src/lib/auth/auth-context.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/lib/auth/auth-context.tsx b/apps/web/src/lib/auth/auth-context.tsx index f02a06f..5a61826 100644 --- a/apps/web/src/lib/auth/auth-context.tsx +++ b/apps/web/src/lib/auth/auth-context.tsx @@ -1,3 +1,5 @@ +'use client'; + import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; export interface User { From 517a1f8b2f2e5eaeada4128c0732ec047a986cf4 Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 18:10:00 +0530 Subject: [PATCH 12/26] fix(dashboard): convert to client component and add layout with metadata --- apps/web/src/app/dashboard/layout.tsx | 10 ++++++++++ apps/web/src/app/dashboard/page.tsx | 8 ++------ 2 files changed, 12 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/app/dashboard/layout.tsx diff --git a/apps/web/src/app/dashboard/layout.tsx b/apps/web/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..5059820 --- /dev/null +++ b/apps/web/src/app/dashboard/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Dashboard', + description: 'DevRadar Dashboard - Manage your account and view your coding activity.', +}; + +export default function DashboardLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index 292662d..7f87e04 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -1,4 +1,5 @@ -import type { Metadata } from 'next'; +'use client'; + import Link from 'next/link'; import Image from 'next/image'; import { Lock, ExternalLink, Calendar, Award, TrendingUp } from 'lucide-react'; @@ -10,11 +11,6 @@ import { Badge } from '@/components/ui/badge'; import { SITE_CONFIG } from '@/lib/constants'; import { useAuth } from '@/lib/auth'; -export const metadata: Metadata = { - title: 'Dashboard', - description: 'DevRadar Dashboard - Manage your account and view your coding activity.', -}; - function SignedOutView() { const { signIn } = useAuth(); From d9bbd3ce2ac0c1e667f8cab0df7330eb343f7a5f Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 18:27:31 +0530 Subject: [PATCH 13/26] refactor(env): use env vars for all hardcoded domains and URLs --- .env.example | 69 ++++------------- apps/extension/.env.example | 26 +++++++ apps/extension/src/extension.ts | 12 +-- .../src/services/featureGatingService.ts | 8 +- apps/server/.env.example | 74 +++++++++++++++++++ apps/server/src/integrations/slack.ts | 6 +- apps/server/src/routes/slack.ts | 2 +- apps/server/src/server.ts | 2 +- apps/web/.env.example | 23 ++++++ apps/web/.gitignore | 2 +- apps/web/src/lib/auth/api.ts | 2 +- apps/web/src/lib/auth/auth-context.tsx | 2 +- 12 files changed, 147 insertions(+), 81 deletions(-) create mode 100644 apps/extension/.env.example create mode 100644 apps/server/.env.example create mode 100644 apps/web/.env.example diff --git a/.env.example b/.env.example index 458caeb..e606894 100644 --- a/.env.example +++ b/.env.example @@ -1,54 +1,15 @@ -# Node Environment -NODE_ENV=development - -# Server Configuration -PORT=3000 -HOST=localhost - -# Database (PostgreSQL) -DATABASE_URL=postgresql://devradar:devradar@localhost:5432/devradar?schema=public - -# Redis -REDIS_URL=redis://localhost:6379 - -# GitHub OAuth -GITHUB_CLIENT_ID=your_github_client_id -GITHUB_CLIENT_SECRET=your_github_client_secret -GITHUB_CALLBACK_URL=http://localhost:3000/auth/callback -# GitHub Webhooks (for Boss Battles - achievements from GitHub events) -# Generate with: openssl rand -hex 32 -GITHUB_WEBHOOK_SECRET=your_webhook_secret_for_github - -# JWT -JWT_SECRET=your_super_secret_jwt_key_change_in_production -JWT_EXPIRES_IN=7d - -# WebSocket -WS_PORT=3001 - -# Logging -LOG_LEVEL=debug - -# Slack Integration (Optional) -# Create a Slack App at https://api.slack.com/apps -SLACK_CLIENT_ID= -SLACK_CLIENT_SECRET= -SLACK_SIGNING_SECRET= - -# Razorpay Billing -# Get your API keys from https://dashboard.razorpay.com/#/app/keys -RAZORPAY_KEY_ID=your_razorpay_key_id -RAZORPAY_KEY_SECRET=your_razorpay_key_secret -RAZORPAY_WEBHOOK_SECRET=your_razorpay_webhook_secret - -# Razorpay Plan IDs -RAZORPAY_PRO_MONTHLY_PLAN_ID=plan_pro_monthly -RAZORPAY_PRO_ANNUAL_PLAN_ID=plan_pro_annual -RAZORPAY_TEAM_MONTHLY_PLAN_ID=plan_team_monthly -RAZORPAY_TEAM_ANNUAL_PLAN_ID=plan_team_annual - -# Web App URL (for redirect URLs) -WEB_APP_URL=http://localhost:3000 - -# Frontend Environment Variables -NEXT_PUBLIC_RAZORPAY_KEY_ID=your_razorpay_key_id +# ============================================ +# DevRadar Environment Configuration +# ============================================ + +# Environment variables are now organized per application: +# +# - Server variables: apps/server/.env.example +# - Web variables: apps/web/.env.example +# +# Copy the appropriate .env.example to .env in each app directory +# and fill in your values before running locally. +# +# For production deployment: +# - Vercel: Add NEXT_PUBLIC_RAZORPAY_KEY_ID in Vercel dashboard +# - Koyeb: Add all vars from apps/server/.env.example in Koyeb dashboard diff --git a/apps/extension/.env.example b/apps/extension/.env.example new file mode 100644 index 0000000..0b189b2 --- /dev/null +++ b/apps/extension/.env.example @@ -0,0 +1,26 @@ +# ============================================ +# DevRadar VS Code Extension Environment Configuration +# ============================================ +# Copy this file to .env and fill in your values + +# ----------------------------------------------------------------------------- +# DEPLOYMENT: Add these in VS Code Extension settings or .env file +# ----------------------------------------------------------------------------- + +# Development Server URLs (for local development) +DEV_SERVER_URL=http://localhost:3000 +DEV_WS_URL=ws://localhost:3000/ws + +# Production Server URLs (for Koyeb deployment) +# Replace with your Koyeb app URL after deployment +PROD_SERVER_URL=https://your-koyeb-app.koyeb.app +PROD_WS_URL=wss://your-koyeb-app.koyeb.app/ws + +# Web Application URL (for links from extension to web app) +# Update this to your Vercel deployment URL in production +NEXT_PUBLIC_WEB_APP_URL=http://localhost:3000 + +# ----------------------------------------------------------------------------- +# PRODUCTION VALUES (update these when deploying) +# ----------------------------------------------------------------------------- +# NEXT_PUBLIC_WEB_APP_URL=https://devradar.io diff --git a/apps/extension/src/extension.ts b/apps/extension/src/extension.ts index 494b9b2..e7052b1 100644 --- a/apps/extension/src/extension.ts +++ b/apps/extension/src/extension.ts @@ -81,11 +81,7 @@ class DevRadarExtension implements vscode.Disposable { this.statsProvider = new StatsProvider(this.logger); this.leaderboardProvider = new LeaderboardProvider(this.logger); // Phase 5: Feature Gating - this.featureGatingService = new FeatureGatingService( - this.authService, - this.configManager, - this.logger - ); + this.featureGatingService = new FeatureGatingService(this.authService, this.logger); /* Track disposables */ this.disposables.push( this.authService, @@ -808,11 +804,7 @@ class DevRadarExtension implements vscode.Disposable { } private getWebAppUrl(): string { - const serverUrl = this.configManager.get('serverUrl'); - if (serverUrl.includes('localhost') || serverUrl.includes('127.0.0.1')) { - return 'http://localhost:3001'; - } - return 'https://devradar.dev'; + return process.env.NEXT_PUBLIC_WEB_APP_URL || 'http://localhost:3000'; } dispose(): void { diff --git a/apps/extension/src/services/featureGatingService.ts b/apps/extension/src/services/featureGatingService.ts index 736cba4..59798f0 100644 --- a/apps/extension/src/services/featureGatingService.ts +++ b/apps/extension/src/services/featureGatingService.ts @@ -8,7 +8,6 @@ import * as vscode from 'vscode'; import type { AuthService } from './authService'; -import type { ConfigManager } from '../utils/configManager'; import type { Logger } from '../utils/logger'; type Feature = @@ -134,7 +133,6 @@ export class FeatureGatingService implements vscode.Disposable { constructor( private readonly authService: AuthService, - private readonly configManager: ConfigManager, private readonly logger: Logger ) {} @@ -219,11 +217,7 @@ export class FeatureGatingService implements vscode.Disposable { * Gets the web app URL from config or uses default. */ private getWebAppUrl(): string { - const serverUrl = this.configManager.get('serverUrl'); - if (serverUrl.includes('localhost') || serverUrl.includes('127.0.0.1')) { - return 'http://localhost:3001'; - } - return 'https://devradar.dev'; + return process.env.NEXT_PUBLIC_WEB_APP_URL || 'http://localhost:3000'; } dispose(): void { diff --git a/apps/server/.env.example b/apps/server/.env.example new file mode 100644 index 0000000..915d394 --- /dev/null +++ b/apps/server/.env.example @@ -0,0 +1,74 @@ +# ============================================ +# DevRadar Server Environment Configuration +# ============================================ +# Copy this file to .env and fill in your values + +# ----------------------------------------------------------------------------- +# DEPLOYMENT: Add these in Koyeb Dashboard → Service → Environment +# ----------------------------------------------------------------------------- + +# Node Environment +NODE_ENV=development + +# Server Configuration +PORT=3000 +HOST=localhost + +# Database (PostgreSQL via Docker or Neon) +DATABASE_URL=postgresql://user:password@host:5432/devradar?schema=public + +# Redis (via Docker) +REDIS_URL=redis://localhost:6379 + +# GitHub OAuth (Required) +# Create an OAuth App at https://github.com/settings/developers +GITHUB_CLIENT_ID=your_github_client_id +GITHUB_CLIENT_SECRET=your_github_client_secret +GITHUB_CALLBACK_URL=http://localhost:3000/auth/callback + +# GitHub Webhooks (Optional - for Boss Battles achievements) +# Generate with: openssl rand -hex 32 +GITHUB_WEBHOOK_SECRET=your_webhook_secret_for_github + +# JWT (Required - use a strong secret in production) +JWT_SECRET=your_super_secret_jwt_key_change_in_production_min_32_chars +JWT_EXPIRES_IN=7d + +# Security +# Generate with: openssl rand -hex 32 +ENCRYPTION_KEY=your_32_character_minimum_encryption_key + +# API Base URL (Optional - for custom API subdomain) +# API_BASE_URL=https://api.devradar.io + +# Logging +LOG_LEVEL=debug + +# Slack Integration (Optional) +# Create a Slack App at https://api.slack.com/apps +# SLACK_CLIENT_ID= +# SLACK_CLIENT_SECRET= +# SLACK_SIGNING_SECRET= + +# Razorpay Billing (Required for billing features) +# Get your API keys from https://dashboard.razorpay.com/#/app/keys +RAZORPAY_KEY_ID=your_razorpay_key_id +RAZORPAY_KEY_SECRET=your_razorpay_key_secret +RAZORPAY_WEBHOOK_SECRET=your_razorpay_webhook_secret + +# Razorpay Plan IDs (Create these in your Razorpay dashboard) +RAZORPAY_PRO_MONTHLY_PLAN_ID=plan_pro_monthly +RAZORPAY_PRO_ANNUAL_PLAN_ID=plan_pro_annual +RAZORPAY_TEAM_MONTHLY_PLAN_ID=plan_team_monthly +RAZORPAY_TEAM_ANNUAL_PLAN_ID=plan_team_annual + +# Web App URL (for redirect URLs) +WEB_APP_URL=http://localhost:3000 + +# ----------------------------------------------------------------------------- +# PRODUCTION VALUES (update these when deploying to Koyeb) +# ----------------------------------------------------------------------------- +# NODE_ENV=production +# GITHUB_CALLBACK_URL=https://your-koyeb-app.koyeb.app/auth/callback +# WEB_APP_URL=https://your-vercel-app.vercel.app +# LOG_LEVEL=info diff --git a/apps/server/src/integrations/slack.ts b/apps/server/src/integrations/slack.ts index 29433bb..3fe5c99 100644 --- a/apps/server/src/integrations/slack.ts +++ b/apps/server/src/integrations/slack.ts @@ -152,11 +152,7 @@ function getSlackRedirectUri(): string { if (env.API_BASE_URL) { return `${env.API_BASE_URL}/slack/callback`; } - const baseUrl = - env.NODE_ENV === 'production' - ? 'https://api.devradar.io' - : `http://localhost:${String(env.PORT)}`; - return `${baseUrl}/slack/callback`; + return `${env.WEB_APP_URL}/slack/callback`; } /** diff --git a/apps/server/src/routes/slack.ts b/apps/server/src/routes/slack.ts index 18bc5ea..b7e2681 100644 --- a/apps/server/src/routes/slack.ts +++ b/apps/server/src/routes/slack.ts @@ -187,7 +187,7 @@ export function slackRoutes(app: FastifyInstance): void { // In production, redirect to dashboard return reply.redirect( - `https://devradar.io/dashboard/team/${state.teamId}/settings?slack=connected` + `${env.WEB_APP_URL}/dashboard/team/${state.teamId}/settings?slack=connected` ); } ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e49a0fb..aafeb09 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -55,7 +55,7 @@ async function buildServer() { }); await app.register(fastifyCors, { - origin: isDevelopment ? true : ['https://devradar.io', /\.devradar\.io$/], + origin: isDevelopment ? true : [env.WEB_APP_URL], credentials: true, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], }); diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..25204a3 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,23 @@ +# Frontend Environment Variables for DevRadar Web App +# ==================================================== +# Copy this file to .env.local and fill in your values + +# ----------------------------------------------------------------------------- +# DEPLOYMENT: Add these in Vercel Dashboard → Project Settings → Environment Variables +# ----------------------------------------------------------------------------- + +# API Server URL +NEXT_PUBLIC_API_URL=http://localhost:3000 + +# Razorpay Configuration +# Get your key ID from https://dashboard.razorpay.com/#/app/keys +NEXT_PUBLIC_RAZORPAY_KEY_ID=your_razorpay_key_id + +# Web App URL (for links and redirects) +NEXT_PUBLIC_WEB_APP_URL=http://localhost:3000 + +# ----------------------------------------------------------------------------- +# PRODUCTION VALUES (update these when deploying to Vercel) +# ----------------------------------------------------------------------------- +# NEXT_PUBLIC_API_URL=https://your-koyeb-app.koyeb.app +# NEXT_PUBLIC_WEB_APP_URL=https://devradar.io diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 5ef6a52..e72b4d6 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -31,7 +31,7 @@ yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) -.env* +.env # vercel .vercel diff --git a/apps/web/src/lib/auth/api.ts b/apps/web/src/lib/auth/api.ts index b63055b..6b22e0d 100644 --- a/apps/web/src/lib/auth/api.ts +++ b/apps/web/src/lib/auth/api.ts @@ -1,6 +1,6 @@ import type { User } from './auth-context'; -const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; function getAuthHeaders(): HeadersInit { const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null; diff --git a/apps/web/src/lib/auth/auth-context.tsx b/apps/web/src/lib/auth/auth-context.tsx index 5a61826..b0b3b58 100644 --- a/apps/web/src/lib/auth/auth-context.tsx +++ b/apps/web/src/lib/auth/auth-context.tsx @@ -22,7 +22,7 @@ interface AuthContextType { const AuthContext = createContext(undefined); -const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); From eee24c6365a847fe48bcaba14b7f444cae3197b7 Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 20:36:58 +0530 Subject: [PATCH 14/26] fix(razorpay): fix timing attack vulnerability with crypto.timingSafeEqual - Replace string comparison with timing-safe comparison for payment signature verification - Apply same fix to webhook signature verification - Fix total_count type from string to number (5 for annual, 12 for monthly) --- apps/server/src/services/razorpay.ts | 57 +++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/apps/server/src/services/razorpay.ts b/apps/server/src/services/razorpay.ts index a4d9cfb..c5b6434 100644 --- a/apps/server/src/services/razorpay.ts +++ b/apps/server/src/services/razorpay.ts @@ -110,7 +110,10 @@ export async function getOrCreateCustomer(user: UserForBilling): Promise const client = await getClient(); const clientCasted = client as { customers: { - all: () => Promise<{ items: { id: string; email: string | undefined }[] }>; + all: (params: { + count: number; + skip: number; + }) => Promise<{ items: { id: string; email: string | undefined }[] }>; create: (data: Record) => Promise<{ id: string }>; }; }; @@ -121,19 +124,39 @@ export async function getOrCreateCustomer(user: UserForBilling): Promise } if (user.email) { + const userEmail = user.email; try { - const customersList = await clientCasted.customers.all(); - const existingCustomer = customersList.items.find((c) => c.email === user.email); - if (existingCustomer) { + const pageSize = 100; + let skip = 0; + let foundCustomer: { id: string; email: string | undefined } | null = null; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional infinite loop with explicit break conditions + while (true) { + const customersList = await clientCasted.customers.all({ count: pageSize, skip }); + const matchingCustomer = customersList.items.find((c) => c.email === userEmail); + + if (matchingCustomer) { + foundCustomer = matchingCustomer; + break; + } + + if (customersList.items.length < pageSize) { + break; + } + + skip += pageSize; + } + + if (foundCustomer) { await db.user.update({ where: { id: user.id }, - data: { razorpayCustomerId: existingCustomer.id }, + data: { razorpayCustomerId: foundCustomer.id }, }); logger.info( - { userId: user.id, customerId: existingCustomer.id }, + { userId: user.id, customerId: foundCustomer.id }, 'Found existing Razorpay customer' ); - return existingCustomer.id; + return foundCustomer.id; } } catch { logger.warn( @@ -212,7 +235,7 @@ export async function createSubscription( const subscriptionData: Record = { customer_id: customerId, plan_id: planId, - total_count: billingInterval === 'annual' ? '5' : '12', + total_count: billingInterval === 'annual' ? 5 : 12, notes: { userId: user.id, tier, @@ -417,7 +440,14 @@ export function verifyPaymentSignature( .update(payload) .digest('hex'); - return expectedSignature === razorpaySignature; + const expectedBuffer = Buffer.from(expectedSignature, 'hex'); + const signatureBuffer = Buffer.from(razorpaySignature, 'hex'); + + if (expectedBuffer.length !== signatureBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(expectedBuffer, signatureBuffer); } export function verifyWebhookSignature(rawBody: Buffer, signature: string): boolean { @@ -428,5 +458,12 @@ export function verifyWebhookSignature(rawBody: Buffer, signature: string): bool .update(rawBody) .digest('hex'); - return signature === expectedSignature; + const expectedBuffer = Buffer.from(expectedSignature, 'hex'); + const signatureBuffer = Buffer.from(signature, 'hex'); + + if (expectedBuffer.length !== signatureBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(expectedBuffer, signatureBuffer); } From 720bd6a39cb7f8817311866e8205d919082fadd0 Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 20:37:21 +0530 Subject: [PATCH 15/26] fix(billing): add rate limiting and save razorpaySubscriptionId on verify - Add rate limit of 20 requests/minute to /verify endpoint - Save razorpaySubscriptionId in database during payment verification --- apps/server/src/routes/billing.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/server/src/routes/billing.ts b/apps/server/src/routes/billing.ts index 277d4d2..ff330dc 100644 --- a/apps/server/src/routes/billing.ts +++ b/apps/server/src/routes/billing.ts @@ -111,7 +111,15 @@ export function billingRoutes(app: FastifyInstance): void { app.post( '/verify', - { onRequest: [authenticate] }, + { + onRequest: [authenticate], + config: { + rateLimit: { + max: 20, + timeWindow: '1 minute', + }, + }, + }, async (request: FastifyRequest, reply: FastifyReply) => { if (!isRazorpayEnabled()) { throw new InternalError('Billing is not configured'); @@ -150,6 +158,7 @@ export function billingRoutes(app: FastifyInstance): void { where: { id: userId }, data: { tier: subscriptionDetails.notes.tier as 'PRO' | 'TEAM', + razorpaySubscriptionId: razorpaySubscriptionId, razorpayCurrentPeriodEnd: new Date(subscriptionDetails.current_end * 1000), }, }); @@ -161,7 +170,7 @@ export function billingRoutes(app: FastifyInstance): void { app.post( '/webhooks', { - config: { rawBody: true } as Record, + config: { rawBody: true }, }, async (request: FastifyRequest, reply: FastifyReply) => { if (!isRazorpayEnabled()) { From b781540bf7444f9177457cb5815e51182cc0b9b3 Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 20:37:43 +0530 Subject: [PATCH 16/26] fix(webhooks): use userId from subscription notes for database lookup - Fix handleSubscriptionActivated to use userId from notes for DB update - This provides more reliable user lookup than razorpaySubscriptionId --- apps/server/src/services/razorpay-webhooks.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/services/razorpay-webhooks.ts b/apps/server/src/services/razorpay-webhooks.ts index fcc22fa..05f393f 100644 --- a/apps/server/src/services/razorpay-webhooks.ts +++ b/apps/server/src/services/razorpay-webhooks.ts @@ -100,9 +100,10 @@ async function handleSubscriptionActivated( } await db.user.update({ - where: { razorpaySubscriptionId: subscriptionId }, + where: { id: userId }, data: { tier: tier ?? 'PRO', + razorpaySubscriptionId: subscriptionId, razorpayCurrentPeriodEnd: new Date(currentEnd * 1000), }, }); From 8d79585e70dc9d0d48760bb65c0714a72c9081b3 Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 20:38:02 +0530 Subject: [PATCH 17/26] refactor(featureGate): import from shared package and add validation - Remove duplicate types and import from @devradar/shared - Add defensive validation for request.user and user.tier --- apps/server/src/lib/featureGate.ts | 185 +++++++---------------------- 1 file changed, 46 insertions(+), 139 deletions(-) diff --git a/apps/server/src/lib/featureGate.ts b/apps/server/src/lib/featureGate.ts index 095a9c8..b0171c0 100644 --- a/apps/server/src/lib/featureGate.ts +++ b/apps/server/src/lib/featureGate.ts @@ -5,136 +5,20 @@ * based on user subscription tier. */ +import { + type Feature, + type SubscriptionTier, + FEATURE_DESCRIPTIONS, + hasFeatureAccess, + getRequiredTier, + isTierAtLeast, +} from '@devradar/shared'; + import type { FastifyRequest, FastifyReply, preHandlerHookHandler } from 'fastify'; import { AuthorizationError } from '@/lib/errors'; import { getDb } from '@/services/db'; -type Feature = - | 'presence' - | 'friends' - | 'globalLeaderboard' - | 'friendsLeaderboard' - | 'streaks' - | 'achievements' - | 'poke' - | 'privacyMode' - | 'unlimitedFriends' - | 'ghostMode' - | 'customStatus' - | 'history30d' - | 'themes' - | 'customEmoji' - | 'prioritySupport' - | 'conflictRadar' - | 'teamCreation' - | 'teamAnalytics' - | 'slackIntegration' - | 'privateLeaderboards' - | 'adminControls' - | 'ssoSaml' - | 'dedicatedSupport'; - -type SubscriptionTier = 'FREE' | 'PRO' | 'TEAM'; - -const FREE_FEATURES: readonly Feature[] = [ - 'presence', - 'friends', - 'globalLeaderboard', - 'friendsLeaderboard', - 'streaks', - 'achievements', - 'poke', - 'privacyMode', -]; - -const PRO_ADDITIONAL: readonly Feature[] = [ - 'unlimitedFriends', - 'ghostMode', - 'customStatus', - 'history30d', - 'themes', - 'customEmoji', - 'prioritySupport', -]; - -const TEAM_ADDITIONAL: readonly Feature[] = [ - 'conflictRadar', - 'teamCreation', - 'teamAnalytics', - 'slackIntegration', - 'privateLeaderboards', - 'adminControls', - 'ssoSaml', - 'dedicatedSupport', -]; - -const SUBSCRIPTION_FEATURES: Record = { - FREE: FREE_FEATURES, - PRO: [...FREE_FEATURES, ...PRO_ADDITIONAL], - TEAM: [...FREE_FEATURES, ...PRO_ADDITIONAL, ...TEAM_ADDITIONAL], -}; - -const FEATURE_TIER_MAP: Record = { - presence: 'FREE', - friends: 'FREE', - globalLeaderboard: 'FREE', - friendsLeaderboard: 'FREE', - streaks: 'FREE', - achievements: 'FREE', - poke: 'FREE', - privacyMode: 'FREE', - unlimitedFriends: 'PRO', - ghostMode: 'PRO', - customStatus: 'PRO', - history30d: 'PRO', - themes: 'PRO', - customEmoji: 'PRO', - prioritySupport: 'PRO', - conflictRadar: 'TEAM', - teamCreation: 'TEAM', - teamAnalytics: 'TEAM', - slackIntegration: 'TEAM', - privateLeaderboards: 'TEAM', - adminControls: 'TEAM', - ssoSaml: 'TEAM', - dedicatedSupport: 'TEAM', -}; - -const FEATURE_DESCRIPTIONS: Record = { - presence: 'Real-time presence status', - friends: 'Friends list with activity', - globalLeaderboard: 'Global coding leaderboards', - friendsLeaderboard: 'Friends leaderboard', - streaks: 'Coding streak tracking', - achievements: 'GitHub achievements', - poke: 'Poke friends', - privacyMode: 'Hide activity details', - unlimitedFriends: 'Unlimited friends', - ghostMode: 'Go completely invisible', - customStatus: 'Custom status messages', - history30d: '30-day activity history', - themes: 'Custom themes', - customEmoji: 'Custom emoji reactions', - prioritySupport: 'Priority support', - conflictRadar: 'Merge conflict detection', - teamCreation: 'Create and manage teams', - teamAnalytics: 'Team analytics dashboard', - slackIntegration: 'Slack integration', - privateLeaderboards: 'Private team leaderboards', - adminControls: 'Admin controls', - ssoSaml: 'SSO & SAML authentication', - dedicatedSupport: 'Dedicated support', -}; - -function hasFeatureAccess(tier: SubscriptionTier, feature: Feature): boolean { - return SUBSCRIPTION_FEATURES[tier].includes(feature); -} - -function getRequiredTier(feature: Feature): SubscriptionTier { - return FEATURE_TIER_MAP[feature]; -} - /** * Creates a Fastify preHandler hook that checks if the user has access to a feature. * Returns a 403 Forbidden error with upgrade information if the user lacks access. @@ -146,7 +30,17 @@ export function requireFeature(feature: Feature): preHandlerHookHandler { // Fastify preHandler hooks support async functions - Fastify awaits promises internally // eslint-disable-next-line @typescript-eslint/no-misused-promises return async (request: FastifyRequest, _reply: FastifyReply): Promise => { - const { userId } = request.user as { userId: string }; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime check needed + if (!request.user || typeof request.user !== 'object') { + throw new AuthorizationError('User not authenticated'); + } + + const userPayload = request.user as { userId?: unknown }; + if (typeof userPayload.userId !== 'string') { + throw new AuthorizationError('Invalid user ID'); + } + + const { userId } = userPayload; const db = getDb(); const user = await db.user.findUnique({ @@ -158,7 +52,10 @@ export function requireFeature(feature: Feature): preHandlerHookHandler { throw new AuthorizationError('User not found'); } - const userTier = user.tier as SubscriptionTier; + const validTiers: SubscriptionTier[] = ['FREE', 'PRO', 'TEAM']; + const userTier = validTiers.includes(user.tier as SubscriptionTier) + ? (user.tier as SubscriptionTier) + : 'FREE'; if (!hasFeatureAccess(userTier, feature)) { const requiredTier = getRequiredTier(feature); @@ -182,7 +79,17 @@ export function requireTier(requiredTier: SubscriptionTier): preHandlerHookHandl // Fastify preHandler hooks support async functions - Fastify awaits promises internally // eslint-disable-next-line @typescript-eslint/no-misused-promises return async (request: FastifyRequest, _reply: FastifyReply): Promise => { - const { userId } = request.user as { userId: string }; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime check needed + if (!request.user || typeof request.user !== 'object') { + throw new AuthorizationError('User not authenticated'); + } + + const userPayload = request.user as { userId?: unknown }; + if (typeof userPayload.userId !== 'string') { + throw new AuthorizationError('Invalid user ID'); + } + + const { userId } = userPayload; const db = getDb(); const user = await db.user.findUnique({ @@ -194,17 +101,12 @@ export function requireTier(requiredTier: SubscriptionTier): preHandlerHookHandl throw new AuthorizationError('User not found'); } - const tierHierarchy: Record = { - FREE: 0, - PRO: 1, - TEAM: 2, - }; - - // tierHierarchy keys are guaranteed by type assertion on user.tier - const userTierLevel = tierHierarchy[user.tier as SubscriptionTier]; - const requiredTierLevel = tierHierarchy[requiredTier]; + const validTiers: SubscriptionTier[] = ['FREE', 'PRO', 'TEAM']; + const userTier = validTiers.includes(user.tier as SubscriptionTier) + ? (user.tier as SubscriptionTier) + : 'FREE'; - if (userTierLevel < requiredTierLevel) { + if (!isTierAtLeast(userTier, requiredTier)) { throw new AuthorizationError( `This feature requires ${requiredTier} tier. Upgrade at /dashboard/billing` ); @@ -232,5 +134,10 @@ export async function checkFeatureAccess(userId: string, feature: Feature): Prom return false; } - return hasFeatureAccess(user.tier as SubscriptionTier, feature); + const validTiers: SubscriptionTier[] = ['FREE', 'PRO', 'TEAM']; + const userTier = validTiers.includes(user.tier as SubscriptionTier) + ? (user.tier as SubscriptionTier) + : 'FREE'; + + return hasFeatureAccess(userTier, feature); } From 578dd1049e8daabe5faba18228d517f81363ffa1 Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 20:38:18 +0530 Subject: [PATCH 18/26] refactor(extension): deduplicate getWebAppUrl and import from shared - Remove duplicate getWebAppUrl method from Extension class - Make getWebAppUrl public in FeatureGatingService - Import types and constants from @devradar/shared package - Remove unused disposables field --- apps/extension/src/extension.ts | 6 +- .../src/services/featureGatingService.ts | 110 ++---------------- 2 files changed, 12 insertions(+), 104 deletions(-) diff --git a/apps/extension/src/extension.ts b/apps/extension/src/extension.ts index e7052b1..3deb155 100644 --- a/apps/extension/src/extension.ts +++ b/apps/extension/src/extension.ts @@ -795,7 +795,7 @@ class DevRadarExtension implements vscode.Disposable { } private async handleOpenBilling(): Promise { - const webAppUrl = this.getWebAppUrl(); + const webAppUrl = this.featureGatingService.getWebAppUrl(); const tier = this.featureGatingService.getCurrentTier(); const billingUrl = `${webAppUrl}/dashboard/billing?current=${tier}`; @@ -803,10 +803,6 @@ class DevRadarExtension implements vscode.Disposable { this.logger.info('Opened billing page', { currentTier: tier }); } - private getWebAppUrl(): string { - return process.env.NEXT_PUBLIC_WEB_APP_URL || 'http://localhost:3000'; - } - dispose(): void { this.logger.info('Disposing DevRadar extension...'); diff --git a/apps/extension/src/services/featureGatingService.ts b/apps/extension/src/services/featureGatingService.ts index 59798f0..356b89c 100644 --- a/apps/extension/src/services/featureGatingService.ts +++ b/apps/extension/src/services/featureGatingService.ts @@ -5,76 +5,17 @@ * Checks user tier and prompts for upgrade when accessing gated features. */ +import { + type Feature, + type SubscriptionTier, + SUBSCRIPTION_FEATURES, + FEATURE_DESCRIPTIONS, +} from '@devradar/shared'; import * as vscode from 'vscode'; import type { AuthService } from './authService'; import type { Logger } from '../utils/logger'; -type Feature = - | 'presence' - | 'friends' - | 'globalLeaderboard' - | 'friendsLeaderboard' - | 'streaks' - | 'achievements' - | 'poke' - | 'privacyMode' - | 'unlimitedFriends' - | 'ghostMode' - | 'customStatus' - | 'history30d' - | 'themes' - | 'customEmoji' - | 'prioritySupport' - | 'conflictRadar' - | 'teamCreation' - | 'teamAnalytics' - | 'slackIntegration' - | 'privateLeaderboards' - | 'adminControls' - | 'ssoSaml' - | 'dedicatedSupport'; - -type SubscriptionTier = 'FREE' | 'PRO' | 'TEAM'; - -const FREE_FEATURES: readonly Feature[] = [ - 'presence', - 'friends', - 'globalLeaderboard', - 'friendsLeaderboard', - 'streaks', - 'achievements', - 'poke', - 'privacyMode', -]; - -const PRO_ADDITIONAL: readonly Feature[] = [ - 'unlimitedFriends', - 'ghostMode', - 'customStatus', - 'history30d', - 'themes', - 'customEmoji', - 'prioritySupport', -]; - -const TEAM_ADDITIONAL: readonly Feature[] = [ - 'conflictRadar', - 'teamCreation', - 'teamAnalytics', - 'slackIntegration', - 'privateLeaderboards', - 'adminControls', - 'ssoSaml', - 'dedicatedSupport', -]; - -const SUBSCRIPTION_FEATURES: Record = { - FREE: FREE_FEATURES, - PRO: [...FREE_FEATURES, ...PRO_ADDITIONAL], - TEAM: [...FREE_FEATURES, ...PRO_ADDITIONAL, ...TEAM_ADDITIONAL], -}; - const FEATURE_TIER_MAP: Record = { presence: 'FREE', friends: 'FREE', @@ -99,38 +40,10 @@ const FEATURE_TIER_MAP: Record = { adminControls: 'TEAM', ssoSaml: 'TEAM', dedicatedSupport: 'TEAM', -}; - -const FEATURE_DESCRIPTIONS: Record = { - presence: 'Real-time presence status', - friends: 'Friends list with activity', - globalLeaderboard: 'Global coding leaderboards', - friendsLeaderboard: 'Friends leaderboard', - streaks: 'Coding streak tracking', - achievements: 'GitHub achievements', - poke: 'Poke friends', - privacyMode: 'Hide activity details', - unlimitedFriends: 'Unlimited friends', - ghostMode: 'Go completely invisible', - customStatus: 'Custom status messages', - history30d: '30-day activity history', - themes: 'Custom themes', - customEmoji: 'Custom emoji reactions', - prioritySupport: 'Priority support', - conflictRadar: 'Merge conflict detection', - teamCreation: 'Create and manage teams', - teamAnalytics: 'Team analytics dashboard', - slackIntegration: 'Slack integration', - privateLeaderboards: 'Private team leaderboards', - adminControls: 'Admin controls', - ssoSaml: 'SSO & SAML authentication', - dedicatedSupport: 'Dedicated support', -}; +} as const; /** Manages feature access control and upgrade prompts. */ export class FeatureGatingService implements vscode.Disposable { - private readonly disposables: vscode.Disposable[] = []; - constructor( private readonly authService: AuthService, private readonly logger: Logger @@ -215,14 +128,13 @@ export class FeatureGatingService implements vscode.Disposable { /** * Gets the web app URL from config or uses default. + * @returns The web application URL */ - private getWebAppUrl(): string { - return process.env.NEXT_PUBLIC_WEB_APP_URL || 'http://localhost:3000'; + getWebAppUrl(): string { + return process.env.NEXT_PUBLIC_WEB_APP_URL ?? 'http://localhost:3000'; } dispose(): void { - for (const disposable of this.disposables) { - disposable.dispose(); - } + // No disposables to clean up } } From 9276a54e3656c672fa0c92c830f5302e46e7a525 Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 20:38:45 +0530 Subject: [PATCH 19/26] fix(privacy): fix spelling RazorPay to Razorpay --- apps/web/src/app/privacy/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index a77f56a..96718a1 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -243,7 +243,7 @@ export default function PrivacyPage() {
  • - RazorPay - For payment processing + Razorpay - For payment processing
  • From 4725f1ae2d7dfb85f9bc03fe894ffb10f43b6bb2 Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 20:39:10 +0530 Subject: [PATCH 20/26] fix(pricing): fix Team tier monthly price to 249 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Align price with annualPrice comment (₹249/month) - Previously showed ₹499 which was inconsistent --- apps/web/src/lib/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/constants.ts b/apps/web/src/lib/constants.ts index edfef70..c4e8ed9 100644 --- a/apps/web/src/lib/constants.ts +++ b/apps/web/src/lib/constants.ts @@ -138,7 +138,7 @@ export const PRICING_TIERS = [ { id: 'team', name: 'Team', - price: 499, + price: 249, annualPrice: 2988, // ₹249/month billed annually (₹2988/year) priceNote: 'per user', description: 'For distributed teams', From a0d5b8f7c0c72aca53ece28e0e7108e305095d49 Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 20:39:29 +0530 Subject: [PATCH 21/26] feat(billing): improve UX with toast notifications and useCallback - Replace alert() calls with toast notifications using sonner - Wrap fetchBillingStatus in useCallback for proper memoization - Fix monthly price calculation using annualPrice/12 instead of hardcoded 50% - Add Toaster component to root layout - Fix verification error redirect to use ?verification_failed=true --- apps/web/src/app/dashboard/billing/page.tsx | 47 +++++++++++---------- apps/web/src/app/layout.tsx | 2 + apps/web/src/components/ui/sonner.tsx | 40 ++++++++++++++++++ 3 files changed, 67 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/components/ui/sonner.tsx diff --git a/apps/web/src/app/dashboard/billing/page.tsx b/apps/web/src/app/dashboard/billing/page.tsx index 107b944..d65eff3 100644 --- a/apps/web/src/app/dashboard/billing/page.tsx +++ b/apps/web/src/app/dashboard/billing/page.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useState, useEffect, Suspense } from 'react'; +import { useState, useEffect, Suspense, useCallback } from 'react'; import { useSearchParams } from 'next/navigation'; import Link from 'next/link'; +import { toast } from 'sonner'; import { Check, X, @@ -65,19 +66,7 @@ function BillingPageContent() { const canceled = searchParams.get('canceled') === 'true'; const upgradeTo = searchParams.get('upgrade') as 'PRO' | 'TEAM' | null; - useEffect(() => { - if (!authLoading && !isAuthenticated) { - signIn(); - } - }, [authLoading, isAuthenticated, signIn]); - - useEffect(() => { - if (isAuthenticated) { - fetchBillingStatus(); - } - }, [isAuthenticated]); - - const fetchBillingStatus = async () => { + const fetchBillingStatus = useCallback(async () => { try { const data = await authApi.getBillingStatus(); setBillingStatus({ @@ -89,7 +78,19 @@ function BillingPageContent() { } catch (error) { console.error('Failed to fetch billing status:', error); } - }; + }, []); + + useEffect(() => { + if (!authLoading && !isAuthenticated) { + signIn(); + } + }, [authLoading, isAuthenticated, signIn]); + + useEffect(() => { + if (isAuthenticated) { + fetchBillingStatus(); + } + }, [isAuthenticated, fetchBillingStatus]); const handleCheckout = async (tier: 'PRO' | 'TEAM') => { setLoading(tier); @@ -116,8 +117,9 @@ function BillingPageContent() { response.razorpay_signature ); window.location.href = '/dashboard/billing?success=true'; - } catch { - window.location.href = '/dashboard/billing?canceled=true'; + } catch (error) { + console.error('Payment verification failed:', error); + window.location.href = '/dashboard/billing?verification_failed=true'; } }, prefill: { @@ -135,7 +137,7 @@ function BillingPageContent() { razorpay.open(); } catch (error) { console.error('Checkout failed:', error); - alert('Failed to initialize checkout. Please try again.'); + toast.error('Failed to initialize checkout. Please try again.'); } finally { setLoading(null); } @@ -148,7 +150,7 @@ function BillingPageContent() { if (response.ok) { const data = await response.json(); if (data.hasSubscription) { - alert( + toast.info( 'Subscription management is available through the Razorpay dashboard. Contact support@devradar.dev for assistance.' ); } @@ -173,10 +175,10 @@ function BillingPageContent() { try { await authApi.cancelSubscription(); fetchBillingStatus(); - alert('Subscription cancelled successfully'); + toast.success('Subscription cancelled successfully'); } catch (error) { console.error('Cancel failed:', error); - alert('Failed to cancel subscription. Please try again.'); + toast.error('Failed to cancel subscription. Please try again.'); } finally { setLoading(null); } @@ -342,7 +344,8 @@ function BillingPageContent() {
    {PRICING_TIERS.map((tier) => { - const price = isAnnual ? Math.round(tier.price * 0.5) : tier.price; + const price = + isAnnual && tier.annualPrice > 0 ? Math.round(tier.annualPrice / 12) : tier.price; const isCurrentPlan = tier.id.toUpperCase() === billingStatus.tier; const isHighlighted = tier.highlighted || tier.id.toUpperCase() === upgradeTo; const canUpgrade = diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 86fadfd..dfc4b6c 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next'; import { Space_Mono, Syne, DM_Sans } from 'next/font/google'; import './globals.css'; +import { Toaster } from 'sonner'; import { Header, Footer } from '@/components/layout'; import { SITE_CONFIG } from '@/lib/constants'; @@ -95,6 +96,7 @@ export default function RootLayout({ disableTransitionOnChange > +
    { + const { theme = 'system' } = useTheme(); + + return ( + , + info: , + warning: , + error: , + loading: , + }} + style={ + { + '--normal-bg': 'var(--popover)', + '--normal-text': 'var(--popover-foreground)', + '--normal-border': 'var(--border)', + '--border-radius': 'var(--radius)', + } as React.CSSProperties + } + {...props} + /> + ); +}; + +export { Toaster }; From f0e8f59c424ef064cd8c266756e867f2b39175a1 Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 20:39:58 +0530 Subject: [PATCH 22/26] infra(server): add fastify-raw-body for webhook signature verification - Register fastify-raw-body plugin for raw body access in webhooks - Required for Razorpay webhook signature verification --- apps/server/package.json | 1 + apps/server/src/server.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/apps/server/package.json b/apps/server/package.json index 90f8e59..9aba4a9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -32,6 +32,7 @@ "@slack/web-api": "^7.12.0", "dotenv": "^17.2.3", "fastify": "^5.6.2", + "fastify-raw-body": "^5.0.0", "ioredis": "^5.8.2", "pg": "^8.16.3", "pino": "^10.1.0", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index aafeb09..59d93bd 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -20,6 +20,7 @@ import fastifyJwt from '@fastify/jwt'; import fastifyRateLimit from '@fastify/rate-limit'; import fastifyWebsocket from '@fastify/websocket'; import Fastify, { type FastifyRequest, type FastifyReply } from 'fastify'; +import fastifyRawBody from 'fastify-raw-body'; import { env, isProduction, isDevelopment } from '@/config'; import { toAppError, AuthenticationError } from '@/lib/errors'; @@ -89,6 +90,10 @@ async function buildServer() { }), }); + await app.register(fastifyRawBody, { + global: false, + }); + await app.register(fastifyWebsocket, { options: { maxPayload: 1024 * 64, From 165382b4dc6089cdb4708a1f035bad824fd6fa51 Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 20:40:18 +0530 Subject: [PATCH 23/26] infra(prisma): suppress dotenv informational output with quiet mode - Update dotenv initialization to use config({ quiet: true }) - Reduces console noise during Prisma CLI operations --- apps/server/prisma.config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/prisma.config.ts b/apps/server/prisma.config.ts index 81213fe..0eaae73 100644 --- a/apps/server/prisma.config.ts +++ b/apps/server/prisma.config.ts @@ -5,7 +5,8 @@ * * IMPORTANT: In Prisma 7, env vars must be explicitly loaded with dotenv ***/ -import 'dotenv/config'; +import dotenv from 'dotenv'; +dotenv.config({ quiet: true }); import { defineConfig, env } from 'prisma/config'; export default defineConfig({ From 6dbb29fe09c5262f06382315ee260f31054cfadb Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 20:40:34 +0530 Subject: [PATCH 24/26] deps(web): add sonner for toast notifications - Add sonner package for modern toast notifications - Replaces browser alert() with polished UI notifications --- apps/web/package.json | 1 + pnpm-lock.yaml | 90 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/apps/web/package.json b/apps/web/package.json index dc4b755..c1fb012 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,6 +28,7 @@ "next-themes": "^0.4.6", "react": "19.2.3", "react-dom": "19.2.3", + "sonner": "^2.0.7", "tailwind-merge": "^3.4.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b07b071..acd8938 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ importers: fastify: specifier: ^5.6.2 version: 5.6.2 + fastify-raw-body: + specifier: ^5.0.0 + version: 5.0.0 ioredis: specifier: ^5.8.2 version: 5.8.2 @@ -234,6 +237,9 @@ importers: react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -2300,6 +2306,10 @@ packages: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + c12@3.1.0: resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} peerDependencies: @@ -2523,6 +2533,10 @@ packages: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -2863,6 +2877,10 @@ packages: fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + fastify-raw-body@5.0.0: + resolution: {integrity: sha512-2qfoaQ3BQDhZ1gtbkKZd6n0kKxJISJGM6u/skD9ljdWItAscjXrtZ1lnjr7PavmXX9j4EyCPmBDiIsLn07d5vA==} + engines: {node: '>= 10'} + fastify@5.6.2: resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==} @@ -3120,6 +3138,10 @@ packages: htmlparser2@10.0.0: resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -4109,6 +4131,10 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + razorpay@2.9.6: resolution: {integrity: sha512-zsHAQzd6e1Cc6BNoCNZQaf65ElL6O6yw0wulxmoG5VQDr363fZC90Mp1V5EktVzG45yPyNomNXWlf4cQ3622gQ==} @@ -4301,6 +4327,9 @@ packages: engines: {node: '>=20.0.0'} hasBin: true + secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} @@ -4335,6 +4364,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -4395,6 +4427,12 @@ packages: sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4425,6 +4463,10 @@ packages: standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} @@ -4591,6 +4633,10 @@ packages: resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} engines: {node: '>=12'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + ts-api-utils@2.3.0: resolution: {integrity: sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==} engines: {node: '>=18.12'} @@ -4728,6 +4774,10 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} @@ -6854,6 +6904,8 @@ snapshots: dependencies: run-applescript: 7.1.0 + bytes@3.1.2: {} + c12@3.1.0: dependencies: chokidar: 4.0.3 @@ -7088,6 +7140,8 @@ snapshots: denque@2.1.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} destr@2.0.5: {} @@ -7660,6 +7714,12 @@ snapshots: fastify-plugin@5.1.0: {} + fastify-raw-body@5.0.0: + dependencies: + fastify-plugin: 5.1.0 + raw-body: 3.0.2 + secure-json-parse: 2.7.0 + fastify@5.6.2: dependencies: '@fastify/ajv-compiler': 4.0.5 @@ -7942,6 +8002,14 @@ snapshots: domutils: 3.2.2 entities: 6.0.1 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -8934,6 +9002,13 @@ snapshots: quick-format-unescaped@4.0.4: {} + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.1 + unpipe: 1.0.0 + razorpay@2.9.6: dependencies: axios: 1.13.2 @@ -9145,6 +9220,8 @@ snapshots: transitivePeerDependencies: - supports-color + secure-json-parse@2.7.0: {} + secure-json-parse@4.1.0: {} semver@5.7.2: {} @@ -9179,6 +9256,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setprototypeof@1.2.0: {} + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -9278,6 +9357,11 @@ snapshots: dependencies: atomic-sleep: 1.0.0 + sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + source-map-js@1.2.1: {} spdx-correct@3.2.0: @@ -9302,6 +9386,8 @@ snapshots: standard-as-callback@2.1.0: {} + statuses@2.0.2: {} + std-env@3.9.0: {} steed@1.1.3: @@ -9498,6 +9584,8 @@ snapshots: toad-cache@3.7.0: {} + toidentifier@1.0.1: {} + ts-api-utils@2.3.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -9645,6 +9733,8 @@ snapshots: universalify@2.0.1: {} + unpipe@1.0.0: {} + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.4 From e61b9a5b3e0743688cb1fae36de398bfcb29763b Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 21:30:51 +0530 Subject: [PATCH 25/26] feat(config): add webAppUrl to DevRadarConfig - Add webAppUrl property to DevRadarConfig interface - Add default value (https://devradar.dev) and development override - Load webAppUrl from VS Code configuration --- apps/extension/src/extension.ts | 6 +++++- apps/extension/src/services/featureGatingService.ts | 6 ++++-- apps/extension/src/utils/configManager.ts | 4 ++++ apps/server/src/routes/billing.ts | 2 +- apps/web/src/app/layout.tsx | 2 +- apps/web/src/lib/constants.ts | 4 ++-- 6 files changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/extension/src/extension.ts b/apps/extension/src/extension.ts index 3deb155..88fc887 100644 --- a/apps/extension/src/extension.ts +++ b/apps/extension/src/extension.ts @@ -81,7 +81,11 @@ class DevRadarExtension implements vscode.Disposable { this.statsProvider = new StatsProvider(this.logger); this.leaderboardProvider = new LeaderboardProvider(this.logger); // Phase 5: Feature Gating - this.featureGatingService = new FeatureGatingService(this.authService, this.logger); + this.featureGatingService = new FeatureGatingService( + this.authService, + this.logger, + this.configManager + ); /* Track disposables */ this.disposables.push( this.authService, diff --git a/apps/extension/src/services/featureGatingService.ts b/apps/extension/src/services/featureGatingService.ts index 356b89c..aa0b09d 100644 --- a/apps/extension/src/services/featureGatingService.ts +++ b/apps/extension/src/services/featureGatingService.ts @@ -15,6 +15,7 @@ import * as vscode from 'vscode'; import type { AuthService } from './authService'; import type { Logger } from '../utils/logger'; +import type { ConfigManager } from '../utils/configManager'; const FEATURE_TIER_MAP: Record = { presence: 'FREE', @@ -46,7 +47,8 @@ const FEATURE_TIER_MAP: Record = { export class FeatureGatingService implements vscode.Disposable { constructor( private readonly authService: AuthService, - private readonly logger: Logger + private readonly logger: Logger, + private readonly configManager: ConfigManager ) {} /** @@ -131,7 +133,7 @@ export class FeatureGatingService implements vscode.Disposable { * @returns The web application URL */ getWebAppUrl(): string { - return process.env.NEXT_PUBLIC_WEB_APP_URL ?? 'http://localhost:3000'; + return this.configManager.get('webAppUrl') ?? 'http://localhost:3000'; } dispose(): void { diff --git a/apps/extension/src/utils/configManager.ts b/apps/extension/src/utils/configManager.ts index b12e232..8d0dbde 100644 --- a/apps/extension/src/utils/configManager.ts +++ b/apps/extension/src/utils/configManager.ts @@ -11,6 +11,7 @@ import * as vscode from 'vscode'; export interface DevRadarConfig { serverUrl: string; wsUrl: string; + webAppUrl: string; privacyMode: boolean; showFileName: boolean; showProject: boolean; @@ -27,10 +28,12 @@ const DEFAULT_CONFIG: DevRadarConfig = { /* Production */ serverUrl: 'https://wispy-netti-devradar-c95bfbd3.koyeb.app', wsUrl: 'wss://wispy-netti-devradar-c95bfbd3.koyeb.app/ws', + webAppUrl: 'https://devradar.dev', /* Development */ // serverUrl: 'http://localhost:3000', // wsUrl: 'ws://localhost:3000/ws', + // webAppUrl: 'http://localhost:3000', privacyMode: false, showFileName: true, showProject: true, @@ -96,6 +99,7 @@ export class ConfigManager implements vscode.Disposable { return { serverUrl: config.get('serverUrl') ?? DEFAULT_CONFIG.serverUrl, wsUrl: config.get('wsUrl') ?? DEFAULT_CONFIG.wsUrl, + webAppUrl: config.get('webAppUrl') ?? DEFAULT_CONFIG.webAppUrl, privacyMode: config.get('privacyMode') ?? DEFAULT_CONFIG.privacyMode, showFileName: config.get('showFileName') ?? DEFAULT_CONFIG.showFileName, showProject: config.get('showProject') ?? DEFAULT_CONFIG.showProject, diff --git a/apps/server/src/routes/billing.ts b/apps/server/src/routes/billing.ts index ff330dc..10be01d 100644 --- a/apps/server/src/routes/billing.ts +++ b/apps/server/src/routes/billing.ts @@ -170,7 +170,7 @@ export function billingRoutes(app: FastifyInstance): void { app.post( '/webhooks', { - config: { rawBody: true }, + config: { rawBody: true, rateLimit: false }, }, async (request: FastifyRequest, reply: FastifyReply) => { if (!isRazorpayEnabled()) { diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index dfc4b6c..4175d0b 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next'; import { Space_Mono, Syne, DM_Sans } from 'next/font/google'; import './globals.css'; -import { Toaster } from 'sonner'; +import { Toaster } from '@/components/ui/sonner'; import { Header, Footer } from '@/components/layout'; import { SITE_CONFIG } from '@/lib/constants'; diff --git a/apps/web/src/lib/constants.ts b/apps/web/src/lib/constants.ts index c4e8ed9..794fdb3 100644 --- a/apps/web/src/lib/constants.ts +++ b/apps/web/src/lib/constants.ts @@ -138,8 +138,8 @@ export const PRICING_TIERS = [ { id: 'team', name: 'Team', - price: 249, - annualPrice: 2988, // ₹249/month billed annually (₹2988/year) + price: 499, + annualPrice: 2999, // ~50% discount vs monthly (499 * 12 = 5988) priceNote: 'per user', description: 'For distributed teams', razorpayPlanIds: { From 1a4fffc9d15232abb6df4515721c82635ae6cecc Mon Sep 17 00:00:00 2001 From: Utpal Sen Date: Wed, 14 Jan 2026 21:43:04 +0530 Subject: [PATCH 26/26] fix(extension): resolve lint warnings in FeatureGatingService - Fix import order for ConfigManager type import - Add eslint-disable for configManager.get() possibly null check --- apps/extension/src/services/featureGatingService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/extension/src/services/featureGatingService.ts b/apps/extension/src/services/featureGatingService.ts index aa0b09d..eabbc1c 100644 --- a/apps/extension/src/services/featureGatingService.ts +++ b/apps/extension/src/services/featureGatingService.ts @@ -14,8 +14,8 @@ import { import * as vscode from 'vscode'; import type { AuthService } from './authService'; -import type { Logger } from '../utils/logger'; import type { ConfigManager } from '../utils/configManager'; +import type { Logger } from '../utils/logger'; const FEATURE_TIER_MAP: Record = { presence: 'FREE', @@ -133,6 +133,7 @@ export class FeatureGatingService implements vscode.Disposable { * @returns The web application URL */ getWebAppUrl(): string { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- config.get() can return undefined return this.configManager.get('webAppUrl') ?? 'http://localhost:3000'; }