diff --git a/.env.example b/.env.example index 60f4550..e606894 100644 --- a/.env.example +++ b/.env.example @@ -1,36 +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 (Phase 3 - Optional) -# Create a Slack App at https://api.slack.com/apps -SLACK_CLIENT_ID= -SLACK_CLIENT_SECRET= -SLACK_SIGNING_SECRET= +# ============================================ +# 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/.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 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 d313e00..88fc887 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.logger, + this.configManager + ); /* 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,34 @@ 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.featureGatingService.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 }); + } + dispose(): void { this.logger.info('Disposing DevRadar extension...'); diff --git a/apps/extension/src/services/featureGatingService.ts b/apps/extension/src/services/featureGatingService.ts new file mode 100644 index 0000000..eabbc1c --- /dev/null +++ b/apps/extension/src/services/featureGatingService.ts @@ -0,0 +1,143 @@ +/** + * 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 { + type Feature, + type SubscriptionTier, + SUBSCRIPTION_FEATURES, + FEATURE_DESCRIPTIONS, +} from '@devradar/shared'; +import * as vscode from 'vscode'; + +import type { AuthService } from './authService'; +import type { ConfigManager } from '../utils/configManager'; +import type { Logger } from '../utils/logger'; + +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; + +/** Manages feature access control and upgrade prompts. */ +export class FeatureGatingService implements vscode.Disposable { + constructor( + private readonly authService: AuthService, + private readonly logger: Logger, + private readonly configManager: ConfigManager + ) {} + + /** + * 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. + * @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'; + } + + dispose(): void { + // No disposables to clean up + } +} 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/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/.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/package.json b/apps/server/package.json index 441556e..9aba4a9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -30,14 +30,16 @@ "@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", + "fastify-raw-body": "^5.0.0", "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 +47,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/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({ 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/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/lib/featureGate.ts b/apps/server/src/lib/featureGate.ts new file mode 100644 index 0000000..b0171c0 --- /dev/null +++ b/apps/server/src/lib/featureGate.ts @@ -0,0 +1,143 @@ +/** + * Feature gate utilities for tier-based access control. + * + * Provides Fastify preHandler hooks for restricting route access + * 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'; + +/** + * 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 => { + // 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({ + where: { id: userId }, + select: { tier: true }, + }); + + if (!user) { + throw new AuthorizationError('User not found'); + } + + 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); + 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 => { + // 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({ + where: { id: userId }, + select: { tier: true }, + }); + + if (!user) { + throw new AuthorizationError('User not found'); + } + + const validTiers: SubscriptionTier[] = ['FREE', 'PRO', 'TEAM']; + const userTier = validTiers.includes(user.tier as SubscriptionTier) + ? (user.tier as SubscriptionTier) + : 'FREE'; + + if (!isTierAtLeast(userTier, requiredTier)) { + 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; + } + + const validTiers: SubscriptionTier[] = ['FREE', 'PRO', 'TEAM']; + const userTier = validTiers.includes(user.tier as SubscriptionTier) + ? (user.tier as SubscriptionTier) + : 'FREE'; + + return hasFeatureAccess(userTier, feature); +} diff --git a/apps/server/src/routes/billing.ts b/apps/server/src/routes/billing.ts new file mode 100644 index 0000000..10be01d --- /dev/null +++ b/apps/server/src/routes/billing.ts @@ -0,0 +1,280 @@ +/** + * 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], + config: { + rateLimit: { + max: 20, + timeWindow: '1 minute', + }, + }, + }, + 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', + razorpaySubscriptionId: razorpaySubscriptionId, + razorpayCurrentPeriodEnd: new Date(subscriptionDetails.current_end * 1000), + }, + }); + + return reply.send({ verified: true }); + } + ); + + app.post( + '/webhooks', + { + config: { rawBody: true, rateLimit: false }, + }, + 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/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 b86a43a..59d93bd 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -20,11 +20,13 @@ 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'; 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'; @@ -54,7 +56,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'], }); @@ -88,6 +90,10 @@ async function buildServer() { }), }); + await app.register(fastifyRawBody, { + global: false, + }); + await app.register(fastifyWebsocket, { options: { maxPayload: 1024 * 64, @@ -202,6 +208,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/services/razorpay-webhooks.ts b/apps/server/src/services/razorpay-webhooks.ts new file mode 100644 index 0000000..05f393f --- /dev/null +++ b/apps/server/src/services/razorpay-webhooks.ts @@ -0,0 +1,296 @@ +/** + * 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: { id: userId }, + data: { + tier: tier ?? 'PRO', + razorpaySubscriptionId: subscriptionId, + 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..c5b6434 --- /dev/null +++ b/apps/server/src/services/razorpay.ts @@ -0,0 +1,469 @@ +/** + * 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: (params: { + count: number; + skip: number; + }) => 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) { + const userEmail = user.email; + try { + 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: foundCustomer.id }, + }); + logger.info( + { userId: user.id, customerId: foundCustomer.id }, + 'Found existing Razorpay customer' + ); + return foundCustomer.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'); + + 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 { + const config = getConfig(); + + const expectedSignature = crypto + .createHmac('sha256', config.webhookSecret) + .update(rawBody) + .digest('hex'); + + const expectedBuffer = Buffer.from(expectedSignature, 'hex'); + const signatureBuffer = Buffer.from(signature, 'hex'); + + if (expectedBuffer.length !== signatureBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(expectedBuffer, signatureBuffer); +} 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/.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/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/apps/web/src/app/dashboard/billing/page.tsx b/apps/web/src/app/dashboard/billing/page.tsx new file mode 100644 index 0000000..d65eff3 --- /dev/null +++ b/apps/web/src/app/dashboard/billing/page.tsx @@ -0,0 +1,532 @@ +'use client'; + +import { useState, useEffect, Suspense, useCallback } from 'react'; +import { useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { toast } from 'sonner'; +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 { authApi, useAuth } from '@/lib/auth'; +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 { 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; + + const fetchBillingStatus = useCallback(async () => { + try { + 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); + } + }, []); + + useEffect(() => { + if (!authLoading && !isAuthenticated) { + signIn(); + } + }, [authLoading, isAuthenticated, signIn]); + + useEffect(() => { + if (isAuthenticated) { + fetchBillingStatus(); + } + }, [isAuthenticated, fetchBillingStatus]); + + const handleCheckout = async (tier: 'PRO' | 'TEAM') => { + setLoading(tier); + try { + await loadRazorpayScript(); + + const checkoutData = await authApi.createCheckout(tier, isAnnual ? 'annual' : 'monthly'); + + const options = { + key: checkoutData.keyId, + name: 'DevRadar', + description: `${tier} Plan - ${isAnnual ? 'Annual' : 'Monthly'}`, + order_id: checkoutData.orderId, + subscription_id: checkoutData.subscriptionId, + handler: async (response: { + razorpay_payment_id: string; + razorpay_subscription_id: string; + razorpay_signature: string; + }) => { + try { + await authApi.verifyPayment( + response.razorpay_payment_id, + response.razorpay_subscription_id, + response.razorpay_signature + ); + window.location.href = '/dashboard/billing?success=true'; + } catch (error) { + console.error('Payment verification failed:', error); + window.location.href = '/dashboard/billing?verification_failed=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); + toast.error('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) { + toast.info( + '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 { + await authApi.cancelSubscription(); + fetchBillingStatus(); + toast.success('Subscription cancelled successfully'); + } catch (error) { + console.error('Cancel failed:', error); + toast.error('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 ( +
+ + {!isAuthenticated && !authLoading && ( +
+

Redirecting to sign in...

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

Loading...

+
+ )} + + {isAuthenticated && success && ( +
+ +
+

Subscription activated!

+

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

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

Checkout canceled

+

No worries! You can upgrade anytime.

+
+
+ )} + + {isAuthenticated && ( + <> +
+

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 && 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 = + (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/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 7272554..7f87e04 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -1,17 +1,19 @@ -import type { Metadata } from 'next'; +'use client'; + 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.', -}; +function SignedOutView() { + const { signIn } = useAuth(); -export default function DashboardPage() { return (
@@ -26,6 +28,17 @@ export default function DashboardPage() { friends.

+ +
@@ -64,3 +77,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..4175d0b 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,11 +1,12 @@ import type { Metadata } from 'next'; import { Space_Mono, Syne, DM_Sans } from 'next/font/google'; import './globals.css'; +import { Toaster } from '@/components/ui/sonner'; 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 +95,19 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > -
+ + +
-
-
{children}
-
+
+
{children}
+
+
diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index 0e26059..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() {
  • - Stripe - For payment processing + Razorpay - For payment processing
  • diff --git a/apps/web/src/components/layout/header.tsx b/apps/web/src/components/layout/header.tsx index 4bbc181..cb43397 100644 --- a/apps/web/src/components/layout/header.tsx +++ b/apps/web/src/components/layout/header.tsx @@ -11,12 +11,13 @@ import { useMotionValue, useSpring, } from 'motion/react'; -import { Menu, X, Download, Github, ChevronRight } from 'lucide-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,7 +44,15 @@ function NavItem({ href, label }: { href: string; label: string }) { ); } -function MagneticButton({ children, href }: { children: React.ReactNode; href: 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); @@ -67,6 +76,13 @@ function MagneticButton({ children, href }: { children: React.ReactNode; href: s y.set(0); }; + const handleClick = (e: React.MouseEvent) => { + if (onClick) { + e.preventDefault(); + onClick(); + } + }; + return ( + + + {isOpen && ( + +
    +

    {user.displayName || user.username}

    +

    @{user.username}

    +
    +
    + setIsOpen(false)} + > + + Dashboard + + setIsOpen(false)} + > + + Billing + +
    +
    + +
    +
    + )} +
    +
  • + ); + } + + return ( + + + Sign In + + ); +} + 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 +249,7 @@ export function Header() { - - - Install - +
    + + Dashboard + + ) : ( + + )}
    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', { diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx new file mode 100644 index 0000000..9a029c6 --- /dev/null +++ b/apps/web/src/components/ui/sonner.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { + CircleCheckIcon, + InfoIcon, + Loader2Icon, + OctagonXIcon, + TriangleAlertIcon, +} from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { Toaster as Sonner, type ToasterProps } from 'sonner'; + +const Toaster = ({ ...props }: ToasterProps) => { + 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 }; diff --git a/apps/web/src/lib/auth/api.ts b/apps/web/src/lib/auth/api.ts new file mode 100644 index 0000000..6b22e0d --- /dev/null +++ b/apps/web/src/lib/auth/api.ts @@ -0,0 +1,96 @@ +import type { User } from './auth-context'; + +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; + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +export async function api(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..b0b3b58 --- /dev/null +++ b/apps/web/src/lib/auth/auth-context.tsx @@ -0,0 +1,112 @@ +'use client'; + +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:3000'; + +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}; +} diff --git a/apps/web/src/lib/constants.ts b/apps/web/src/lib/constants.ts index 92e5c91..794fdb3 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: 2999, // ~50% discount vs monthly (499 * 12 = 5988) 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; 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; 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'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e3acb5..acd8938 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,11 +111,14 @@ 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 + fastify-raw-body: + specifier: ^5.0.0 + version: 5.0.0 ioredis: specifier: ^5.8.2 version: 5.8.2 @@ -128,11 +131,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 +157,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) @@ -231,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 @@ -2297,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: @@ -2520,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'} @@ -2559,6 +2576,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'} @@ -2856,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==} @@ -3113,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'} @@ -4102,6 +4131,13 @@ 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==} + rc-config-loader@4.1.3: resolution: {integrity: sha512-kD7FqML7l800i6pS6pvLyIE2ncbk9Du8Q0gp/4hMPhJU6ZxApkoLcGD8ZeqgiAlfwZ6BlETq6qqe+12DUL207w==} @@ -4291,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==} @@ -4325,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} @@ -4385,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'} @@ -4415,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==} @@ -4581,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'} @@ -4718,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==} @@ -6844,6 +6904,8 @@ snapshots: dependencies: run-applescript: 7.1.0 + bytes@3.1.2: {} + c12@3.1.0: dependencies: chokidar: 4.0.3 @@ -7078,6 +7140,8 @@ snapshots: denque@2.1.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} destr@2.0.5: {} @@ -7114,6 +7178,8 @@ snapshots: dotenv@16.6.1: {} + dotenv@17.2.3: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -7648,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 @@ -7930,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 @@ -8922,6 +9002,19 @@ 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 + transitivePeerDependencies: + - debug + rc-config-loader@4.1.3: dependencies: debug: 4.4.3 @@ -9127,6 +9220,8 @@ snapshots: transitivePeerDependencies: - supports-color + secure-json-parse@2.7.0: {} + secure-json-parse@4.1.0: {} semver@5.7.2: {} @@ -9161,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 @@ -9260,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: @@ -9284,6 +9386,8 @@ snapshots: standard-as-callback@2.1.0: {} + statuses@2.0.2: {} + std-env@3.9.0: {} steed@1.1.3: @@ -9480,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 @@ -9627,6 +9733,8 @@ snapshots: universalify@2.0.1: {} + unpipe@1.0.0: {} + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.4