From b999731ddc20f1faf71b33ae80375dedc4d2d275 Mon Sep 17 00:00:00 2001 From: ArshPathan Date: Sun, 22 Mar 2026 20:21:25 +0530 Subject: [PATCH] feat(billing): implement prepaid credit system and resource tiers --- .claude/settings.local.json | 69 ++++- README.md | 31 ++- apps/api/package.json | 1 + apps/api/src/config/tiers.ts | 57 ++++ apps/api/src/index.ts | 64 +++++ apps/api/src/routes/billing.ts | 211 +++++++++++++++ apps/api/src/routes/projects.ts | 129 ++++++--- apps/api/src/services/razorpay.ts | 50 ++++ apps/api/src/services/runner.ts | 16 +- apps/web/src/app/dashboard/billing/page.tsx | 250 ++++++++++++++++++ apps/web/src/app/dashboard/new/page.tsx | 150 ++++++++++- .../src/app/dashboard/project/[id]/page.tsx | 83 +++++- apps/web/src/app/docs/page.tsx | 93 ++++++- apps/web/src/app/page.tsx | 86 ++++-- apps/web/src/app/privacy/page.tsx | 2 + apps/web/src/app/terms/page.tsx | 71 ++++- apps/web/src/components/PanelLayout.tsx | 2 +- package-lock.json | 92 ++++++- packages/config/index.ts | 3 + .../20260322000000_add_billing/migration.sql | 37 +++ packages/database/prisma/schema.prisma | 25 ++ 21 files changed, 1413 insertions(+), 109 deletions(-) create mode 100644 apps/api/src/config/tiers.ts create mode 100644 apps/api/src/routes/billing.ts create mode 100644 apps/api/src/services/razorpay.ts create mode 100644 apps/web/src/app/dashboard/billing/page.tsx create mode 100644 packages/database/prisma/migrations/20260322000000_add_billing/migration.sql diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ded820a..2f16062 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,74 @@ { "permissions": { "allow": [ - "Bash(git:*)" + "Bash(git:*)", + "Bash(docker compose -f infra/docker/docker-compose.yml up -d --force-recreate 2>&1)", + "Bash(sudo systemctl restart nginx && curl -I -H \"Host: code-host.online\" http://localhost:80 2>&1)", + "Bash(sleep 3 && docker exec codehost-api env | grep -E 'GITHUB_CLIENT|GOOGLE_CLIENT' 2>&1)", + "Bash(curl -s -o /dev/null -w \"%{http_code} redirect:%{redirect_url}\" -H \"Host: api.code-host.online\" http://localhost:80/auth/github 2>&1 && echo && curl -s -o /dev/null -w \"%{http_code} redirect:%{redirect_url}\" -H \"Host: api.code-host.online\" http://localhost:80/auth/google 2>&1)", + "Bash(curl -s -I -H \"Host: api.code-host.online\" http://localhost:80/api/auth/github 2>&1 | head -5 && echo \"---\" && curl -s -H \"Host: api.code-host.online\" http://localhost:80/auth/github 2>&1 | head -5 && echo \"---\" && curl -s -H \"Host: api.code-host.online\" http://localhost:4000/auth/github 2>&1 | head -5)", + "Bash(curl -s http://localhost:4000/ 2>&1 | head -3 && echo \"---\" && curl -s http://localhost:4000/auth/github 2>&1 && echo \"---\" && curl -s http://localhost:4000/auth/google 2>&1)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:4000/auth/oauth/github && echo && curl -s -o /dev/null -w \"%{http_code}\" http://localhost:4000/auth/oauth/google)", + "Bash(docker exec codehost-api env | grep -E 'API_URL|APP_URL' 2>&1)", + "Bash(docker exec codehost-api env | grep DATABASE_URL && echo \"---\" && docker logs codehost-api 2>&1 | grep -i -A5 'PrismaClientInitializationError' | tail -15)", + "Bash(sleep 3 && docker exec codehost-api env | grep DATABASE_URL)", + "Bash(docker exec codehost-db psql -U codehost -d codehost -c \"\\\\dt\" 2>&1 | head -20)", + "Bash(grep -A 30 -B 5 \"newName\\\\|subdomain\\\\|rename\" /home/ubuntu/web/CodeHost/apps/web/src/app/dashboard/project/[id]/page.tsx | head -100)", + "Bash(grep -rn \"subdomain\\\\|rename\" /home/ubuntu/web/CodeHost --include=\"*.md\" --include=\"*.json\" 2>/dev/null | head -20)", + "Bash(/tmp/summary.txt:*)", + "Read(//tmp/**)", + "Bash(npx tsc --noEmit --project apps/api/tsconfig.json 2>&1 | head -30)", + "Bash(npx -w @codehost/api tsc --noEmit 2>&1 | head -30)", + "Bash(find /home/ubuntu/web/CodeHost -name \"config*\" -o -name \"constant*\" | grep -E \"\\\\.\\(ts|js|json\\)$\")", + "Bash(grep -r \"MEMORY_LIMIT\\\\|CPU_LIMIT\\\\|TIMEOUT\" /home/ubuntu/web/CodeHost --include=\"*.ts\" --include=\"*.tsx\" 2>/dev/null | head -20)", + "Bash(grep -r \"Dockerfile\\\\|node:20\\\\|python:3\" /home/ubuntu/web/CodeHost --include=\"*.ts\" --include=\"*.tsx\" --include=\"*.md\" 2>/dev/null | head -20)", + "Bash(docker compose -f infra/docker/docker-compose.yml up -d --build 2>&1)", + "Bash(docker ps --format \"table {{.Names}}\\\\t{{.Status}}\\\\t{{.Ports}}\" | grep codehost)", + "Bash(docker exec codehost-web env | grep -E 'PORT|HOSTNAME' 2>&1)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000 && echo \" \\(web:3000\\)\" ; curl -s -o /dev/null -w \"%{http_code}\" http://localhost:4000 && echo \" \\(api:4000\\)\" ; curl -s -o /dev/null -w \"%{http_code}\" -H \"Host: code-host.online\" http://localhost:9000 && echo \" \\(traefik->web\\)\" ; curl -s -o /dev/null -w \"%{http_code}\" -H \"Host: api.code-host.online\" http://localhost:9000 && echo \" \\(traefik->api\\)\")", + "Read(//etc/nginx/sites-enabled/**)", + "Read(//etc/nginx/conf.d/**)", + "Bash(nginx -T)", + "Bash(curl -s -I -H \"Host: code-host.online\" http://localhost:80 2>&1 | head -10)", + "Bash(curl -s -I -H \"Host: api.code-host.online\" http://localhost:80 2>&1 | head -10)", + "Bash(docker ps --format \"{{.Names}}\\\\t{{.Networks}}\" | grep codehost)", + "Bash(docker inspect codehost-run-5f184bfe-2b82-4926-b34a-b022b49a55e3 --format '{{json .Config.Labels}}' 2>&1 | python3 -m json.tool | grep -i traefik)", + "Bash(curl -s -I -H \"Host: dfghj.code-host.online\" http://localhost:9000 2>&1 | head -5)", + "Bash(curl -s -I -H \"Host: dfghj.code-host.online\" http://localhost:80 2>&1 | head -5)", + "Bash(docker network ls | grep -E \"default|codehost\")", + "Bash(docker exec codehost-proxy wget -q -O- --timeout=3 http://172.21.0.7:80 2>&1 | head -5)", + "Bash(docker exec codehost-run-5f184bfe-2b82-4926-b34a-b022b49a55e3 netstat -tlnp 2>/dev/null || docker exec codehost-run-5f184bfe-2b82-4926-b34a-b022b49a55e3 ss -tlnp 2>/dev/null || docker exec codehost-run-5f184bfe-2b82-4926-b34a-b022b49a55e3 cat /etc/nginx/conf.d/default.conf 2>&1)", + "Bash(docker exec codehost-run-5f184bfe-2b82-4926-b34a-b022b49a55e3 env | grep PORT)", + "Bash(docker exec codehost-run-5f184bfe-2b82-4926-b34a-b022b49a55e3 cat /etc/nginx/conf.d/default.conf 2>&1)", + "Bash(docker image inspect codehost-project-5f184bfe-2b82-4926-b34a-b022b49a55e3 --format '{{json .Config.ExposedPorts}}' 2>&1 | head -5)", + "Bash(docker image inspect codehost-project-5f184bfe-2b82-4926-b34a-b022b49a55e3:0de4f8de-040f-42ce-8991-7ef11fcbcdd6 --format '{{json .Config.ExposedPorts}}' 2>&1)", + "Bash(docker exec codehost-api ls /app/storage/projects/5f184bfe-2b82-4926-b34a-b022b49a55e3/source/ 2>&1)", + "Bash(docker exec codehost-api cat /app/storage/projects/5f184bfe-2b82-4926-b34a-b022b49a55e3/source/Dockerfile 2>&1)", + "Bash(docker exec codehost-api cat /app/storage/projects/5f184bfe-2b82-4926-b34a-b022b49a55e3/source/nginx.conf 2>&1)", + "Bash(docker compose -f infra/docker/docker-compose.yml up -d --build api 2>&1)", + "Bash(docker exec codehost-api ls /app/storage/projects/ 2>&1)", + "Bash(docker exec codehost-api find /app -name \"storage\" -type d 2>&1 | head -5)", + "Bash(docker inspect codehost-api --format '{{json .Mounts}}' 2>&1 | python3 -m json.tool)", + "Bash(docker exec codehost-db psql -U codehost -d codehost -c 'SELECT \"id\", \"name\", \"status\", \"containerId\" FROM \"Project\";' 2>&1)", + "Bash(docker ps --format \"{{.Names}}\" | grep codehost-run)", + "Bash(docker exec codehost-run-59ebf542-5ffa-4d74-9585-282722f51b91 netstat -tlnp 2>/dev/null | grep -v \"127.0.0.11\")", + "Bash(docker inspect codehost-run-59ebf542-5ffa-4d74-9585-282722f51b91 --format '{{index .Config.Labels \"traefik.http.services.codehost-run-59ebf542-5ffa-4d74-9585-282722f51b91.loadbalancer.server.port\"}}' 2>&1)", + "Bash(docker inspect codehost-run-59ebf542-5ffa-4d74-9585-282722f51b91 --format '{{index .Config.Labels \"traefik.http.routers.codehost-run-59ebf542-5ffa-4d74-9585-282722f51b91-subdomain.rule\"}}' 2>&1)", + "Bash(curl -s -I -H \"Host: portfolio.code-host.online\" http://localhost:9000 2>&1 | head -5)", + "Bash(docker ps -a --format \"{{.Names}}\\\\t{{.Status}}\" | grep codehost-run)", + "Bash(curl -s -I -H \"Host: test.code-host.online\" http://localhost:9000 2>&1 | head -5)", + "Bash(curl -s -I -H \"Host: code-host.online\" http://localhost:80 2>&1 | head -5)", + "Bash(curl -s -I -H \"Host: portfolio.code-host.online\" http://localhost:80 2>&1 | head -5)", + "Bash(curl -s -o /dev/null -w \"%{http_code}\" -H \"Host: api.code-host.online\" http://localhost:80/auth/me && echo)", + "Bash(docker exec codehost-db psql -U codehost -d codehost -c \"UPDATE \\\\\"Project\\\\\" SET status = 'running', \\\\\"containerId\\\\\" = \\(SELECT \\\\\"Id\\\\\" FROM \\(SELECT split_part\\(names, '/', 2\\) as \\\\\"Id\\\\\" FROM \\(SELECT 'dummy' as names\\) t\\) s LIMIT 0\\) WHERE id = '59ebf542-5ffa-4d74-9585-282722f51b91' AND status = 'building';\" 2>&1)", + "Bash(docker rm codehost-run-b31b48bd-588b-4945-b091-8b96010d9330 codehost-run-9c8e19d7-29af-48c2-b8fa-20094fd1ccdf codehost-run-6f792b86-a780-45cb-a236-649b1eed9bf7 2>&1)", + "Bash(find /home/ubuntu/web/CodeHost -type f \\\\\\( -name \"*billing*\" -o -name \"*payment*\" -o -name \"*stripe*\" \\\\\\) 2>/dev/null | head -20)", + "Bash(find /home/ubuntu/web/CodeHost -name \".env*\" -o -name \"*.env\" 2>/dev/null | head -10)", + "Bash(cd /home/ubuntu/web/CodeHost/apps/api && npm install razorpay)", + "Bash(cd /home/ubuntu/web/CodeHost && npx prisma migrate dev --name add-billing --schema packages/database/prisma/schema.prisma 2>&1)", + "Bash(cd /home/ubuntu/web/CodeHost && npx prisma migrate diff --from-schema-datamodel packages/database/prisma/schema.prisma --to-schema-datamodel packages/database/prisma/schema.prisma --script 2>&1 | head -5)", + "Bash(npx prisma migrate dev --name add-billing --schema packages/database/prisma/schema.prisma --create-only 2>&1)", + "Bash(npx prisma generate --schema packages/database/prisma/schema.prisma 2>&1)" ] } } diff --git a/README.md b/README.md index 9692725..6d40877 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ - **Safe by Default**: Sandboxed container isolation with strict resource limits (128MB RAM). - **Admin Console**: Enhanced panel for monitoring platform health, users, and detailed deployment stats. - **Multi-Provider Auth**: Sign in with email/password, Google, or GitHub. Email verification included. +- **Prepaid Billing**: Comprehensive system with Razorpay integration, wallet credits, and tiered resource allocation. --- @@ -85,6 +86,11 @@ SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_USER=your-email@gmail.com SMTP_PASS=your-app-password + +# Payments (https://dashboard.razorpay.com/app/keys) +RAZORPAY_KEY_ID= +RAZORPAY_KEY_SECRET= +RAZORPAY_WEBHOOK_SECRET= ``` ### 3. OAuth Provider Setup @@ -146,7 +152,22 @@ sudo ufw allow 443/tcp --- -## Administrative Access +## Billing & Resource Tiers + +| Tier | RAM | CPU | Storage | Credits/Month | +|------|-----|-----|---------|---------------| +| **Free** | 128MB | 0.5 | 1GB | ₹0 | +| **Basic** | 256MB | 1.0 | 2GB | ₹100 | +| **Pro** | 512MB | 2.0 | 5GB | ₹300 | +| **Business** | 1GB | 4.0 | 10GB | ₹800 | + +- **Prepaid Model**: Buy credits (1 credit = ₹2), then select a tier for each project. +- **Auto-stop**: If your wallet balance is insufficient for a monthly charge, the project container is automatically stopped to avoid negative balances. +- **Payment Methods**: Seamless integration with Razorpay (UPI, Google Pay, Cards, Netbanking). + +--- + +## Administrative Actions To promote a user to Admin: @@ -154,6 +175,14 @@ To promote a user to Admin: docker exec -it codehost-db psql -U ${DB_USER:-codehost} -d ${DB_NAME:-codehost} -c "UPDATE \"User\" SET role = 'ADMIN' WHERE email = 'your@email.com';" ``` +### Wallet Management (Admin) +Admins can manually adjust user balances or grant credits via the database if necessary: + +```bash +# Add 500 credits to a user +docker exec -it codehost-db psql -U ${DB_USER:-codehost} -d ${DB_NAME:-codehost} -c "UPDATE \"Wallet\" SET balance = balance + 500 WHERE \"userId\" = 'user-uuid';" +``` + --- ## Authentication Flow diff --git a/apps/api/package.json b/apps/api/package.json index 183e291..294730b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -22,6 +22,7 @@ "jsonwebtoken": "^9.0.3", "multer": "^2.0.2", "nodemailer": "^8.0.3", + "razorpay": "^2.9.6", "socket.io": "^4.8.3", "tar-fs": "^3.1.1" }, diff --git a/apps/api/src/config/tiers.ts b/apps/api/src/config/tiers.ts new file mode 100644 index 0000000..0dd6261 --- /dev/null +++ b/apps/api/src/config/tiers.ts @@ -0,0 +1,57 @@ +export interface TierConfig { + name: string; + memory: number; // bytes + cpus: number; // cores + storage: number; // bytes + creditsPerMonth: number; + maxProjects: number; + label: string; +} + +export const RESOURCE_TIERS: Record = { + free: { + name: 'free', + memory: 128 * 1024 * 1024, + cpus: 0.5, + storage: 1 * 1024 * 1024 * 1024, + creditsPerMonth: 0, + maxProjects: 1, + label: 'Free', + }, + basic: { + name: 'basic', + memory: 256 * 1024 * 1024, + cpus: 1, + storage: 2 * 1024 * 1024 * 1024, + creditsPerMonth: 50, + maxProjects: 3, + label: 'Basic', + }, + pro: { + name: 'pro', + memory: 512 * 1024 * 1024, + cpus: 2, + storage: 5 * 1024 * 1024 * 1024, + creditsPerMonth: 150, + maxProjects: 5, + label: 'Pro', + }, + business: { + name: 'business', + memory: 1024 * 1024 * 1024, + cpus: 4, + storage: 10 * 1024 * 1024 * 1024, + creditsPerMonth: 400, + maxProjects: 10, + label: 'Business', + }, +}; + +// 1 credit = ₹2 +export const CREDIT_PRICE_INR = 2; + +export const CREDIT_PACKAGES = [ + { credits: 100, priceInr: 200, label: '100 Credits', savings: null }, + { credits: 300, priceInr: 500, label: '300 Credits', savings: '17% savings' }, + { credits: 600, priceInr: 900, label: '600 Credits', savings: '25% savings' }, +]; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 57d1aab..e47d8ba 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -26,13 +26,16 @@ import projectsRouter from './routes/projects.js'; import deploymentsRouter from './routes/deployments.js'; import adminRouter from './routes/admin.js'; import filesRouter from './routes/files.js'; +import billingRouter from './routes/billing.js'; import { RunnerService } from './services/runner.js'; +import { RESOURCE_TIERS } from './config/tiers.js'; app.use('/auth', authRouter); app.use('/auth/oauth', oauthRouter); app.use('/projects', projectsRouter); app.use('/deployments', deploymentsRouter); app.use('/admin', adminRouter); app.use('/files', filesRouter); +app.use('/billing', billingRouter); // Public Stats (for landing page) app.get('/stats/public', async (req, res) => { @@ -91,6 +94,67 @@ setInterval(async () => { } }, 5000); +// Billing cron: check every 24h for monthly tier charges +setInterval(async () => { + try { + const paidProjects = await prisma.project.findMany({ + where: { tier: { not: 'free' }, status: 'running' }, + include: { user: true }, + }); + + for (const project of paidProjects) { + const tierConfig = RESOURCE_TIERS[project.tier]; + if (!tierConfig || tierConfig.creditsPerMonth === 0) continue; + + const wallet = await prisma.wallet.findUnique({ where: { userId: project.userId } }); + if (!wallet) continue; + + // Check last tier_charge for this project + const lastCharge = await prisma.transaction.findFirst({ + where: { walletId: wallet.id, type: 'tier_charge', projectId: project.id }, + orderBy: { createdAt: 'desc' }, + }); + + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + if (lastCharge && lastCharge.createdAt > thirtyDaysAgo) continue; + + // Deduct credits + if (wallet.balance >= tierConfig.creditsPerMonth) { + await prisma.$transaction(async (tx) => { + await tx.wallet.update({ + where: { id: wallet.id }, + data: { balance: { decrement: tierConfig.creditsPerMonth } }, + }); + await tx.transaction.create({ + data: { + walletId: wallet.id, + amount: -tierConfig.creditsPerMonth, + type: 'tier_charge', + description: `Monthly charge for ${project.name} (${tierConfig.label} tier)`, + projectId: project.id, + }, + }); + }); + logger.info(`Charged ${tierConfig.creditsPerMonth} credits for project ${project.id}`); + } else { + // Insufficient balance: stop container + try { + await RunnerService.stopContainer(project.id); + await prisma.project.update({ + where: { id: project.id }, + data: { status: 'stopped' }, + }); + logger.info(`Stopped project ${project.id} due to insufficient credits`); + } catch (err) { + logger.error({ error: err }, `Failed to stop project ${project.id} for billing`); + } + } + } + } catch (error) { + logger.error({ error }, 'Billing cron error'); + } +}, 24 * 60 * 60 * 1000); + const PORT = env.PORT || 4000; httpServer.listen(PORT, () => { diff --git a/apps/api/src/routes/billing.ts b/apps/api/src/routes/billing.ts new file mode 100644 index 0000000..c8a427c --- /dev/null +++ b/apps/api/src/routes/billing.ts @@ -0,0 +1,211 @@ +import { Router } from 'express'; +import { prisma } from '@codehost/database'; +import { logger } from '@codehost/logger'; +import { requireAuth, AuthRequest } from '../middleware/auth.js'; +import { RESOURCE_TIERS, CREDIT_PACKAGES, CREDIT_PRICE_INR } from '../config/tiers.js'; +import { createOrder, verifySignature, verifyWebhookSignature } from '../services/razorpay.js'; +import express from 'express'; + +const router = Router(); + +// All routes except webhook require auth +router.get('/wallet', requireAuth, async (req: AuthRequest, res) => { + try { + const userId = req.user!.id; + let wallet = await prisma.wallet.findUnique({ where: { userId } }); + if (!wallet) { + wallet = await prisma.wallet.create({ data: { userId } }); + } + res.json({ wallet: { id: wallet.id, balance: wallet.balance } }); + } catch (error) { + logger.error({ error }, 'Get wallet error'); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.get('/transactions', requireAuth, async (req: AuthRequest, res) => { + try { + const userId = req.user!.id; + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 20; + + const wallet = await prisma.wallet.findUnique({ where: { userId } }); + if (!wallet) { + return res.json({ transactions: [], total: 0 }); + } + + const [transactions, total] = await Promise.all([ + prisma.transaction.findMany({ + where: { walletId: wallet.id }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + prisma.transaction.count({ where: { walletId: wallet.id } }), + ]); + + res.json({ transactions, total, page, limit }); + } catch (error) { + logger.error({ error }, 'Get transactions error'); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.get('/tiers', requireAuth, async (_req: AuthRequest, res) => { + const tiers = Object.entries(RESOURCE_TIERS).map(([key, t]) => ({ + id: key, + label: t.label, + memory: t.memory, + cpus: t.cpus, + storage: t.storage, + creditsPerMonth: t.creditsPerMonth, + maxProjects: t.maxProjects, + priceInr: t.creditsPerMonth * CREDIT_PRICE_INR, + })); + res.json({ tiers, creditPackages: CREDIT_PACKAGES, creditPriceInr: CREDIT_PRICE_INR }); +}); + +router.post('/purchase', requireAuth, async (req: AuthRequest, res) => { + try { + const { credits } = req.body; + const pkg = CREDIT_PACKAGES.find((p) => p.credits === credits); + if (!pkg) { + return res.status(400).json({ error: 'Invalid credit package' }); + } + + const order = await createOrder(pkg.priceInr * 100, `wallet-${req.user!.id}`, { + userId: req.user!.id, + credits: String(pkg.credits), + }); + + res.json({ orderId: order.id, amount: pkg.priceInr * 100, currency: 'INR', credits: pkg.credits }); + } catch (error) { + logger.error({ error }, 'Purchase error'); + res.status(500).json({ error: 'Failed to create payment order' }); + } +}); + +router.post('/verify', requireAuth, async (req: AuthRequest, res) => { + try { + const { razorpayOrderId, razorpayPaymentId, razorpaySignature } = req.body; + + if (!razorpayOrderId || !razorpayPaymentId || !razorpaySignature) { + return res.status(400).json({ error: 'Missing payment details' }); + } + + const isValid = verifySignature(razorpayOrderId, razorpayPaymentId, razorpaySignature); + if (!isValid) { + return res.status(400).json({ error: 'Invalid payment signature' }); + } + + // Check idempotency + const existing = await prisma.transaction.findFirst({ + where: { razorpayPaymentId }, + }); + if (existing) { + return res.json({ success: true, message: 'Payment already processed' }); + } + + // Find the credit amount from order notes + const pkg = CREDIT_PACKAGES.find((p) => p.priceInr * 100 === req.body.amount) || CREDIT_PACKAGES[0]; + const creditAmount = req.body.credits || pkg.credits; + + const userId = req.user!.id; + + await prisma.$transaction(async (tx) => { + let wallet = await tx.wallet.findUnique({ where: { userId } }); + if (!wallet) { + wallet = await tx.wallet.create({ data: { userId } }); + } + + await tx.wallet.update({ + where: { id: wallet.id }, + data: { balance: { increment: creditAmount } }, + }); + + await tx.transaction.create({ + data: { + walletId: wallet.id, + amount: creditAmount, + type: 'purchase', + description: `Purchased ${creditAmount} credits`, + razorpayOrderId, + razorpayPaymentId, + }, + }); + }); + + logger.info(`Credits purchased: ${creditAmount} by user ${userId}`); + res.json({ success: true, credits: creditAmount }); + } catch (error) { + logger.error({ error }, 'Verify payment error'); + res.status(500).json({ error: 'Failed to verify payment' }); + } +}); + +// Webhook — no auth, raw body, signature verification +router.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => { + try { + const signature = req.headers['x-razorpay-signature'] as string; + const body = req.body.toString(); + + if (!signature || !verifyWebhookSignature(body, signature)) { + return res.status(400).json({ error: 'Invalid webhook signature' }); + } + + const event = JSON.parse(body); + + if (event.event === 'payment.captured') { + const payment = event.payload.payment.entity; + const orderId = payment.order_id; + const paymentId = payment.id; + const notes = payment.notes || {}; + const userId = notes.userId; + const credits = parseInt(notes.credits) || 0; + + if (!userId || !credits) { + return res.json({ status: 'ok', message: 'Missing notes' }); + } + + // Idempotency check + const existing = await prisma.transaction.findFirst({ + where: { razorpayPaymentId: paymentId }, + }); + if (existing) { + return res.json({ status: 'ok', message: 'Already processed' }); + } + + await prisma.$transaction(async (tx) => { + let wallet = await tx.wallet.findUnique({ where: { userId } }); + if (!wallet) { + wallet = await tx.wallet.create({ data: { userId } }); + } + + await tx.wallet.update({ + where: { id: wallet.id }, + data: { balance: { increment: credits } }, + }); + + await tx.transaction.create({ + data: { + walletId: wallet.id, + amount: credits, + type: 'purchase', + description: `Webhook: Purchased ${credits} credits`, + razorpayOrderId: orderId, + razorpayPaymentId: paymentId, + }, + }); + }); + + logger.info(`Webhook: Credits ${credits} added for user ${userId}`); + } + + res.json({ status: 'ok' }); + } catch (error) { + logger.error({ error }, 'Webhook error'); + res.status(500).json({ error: 'Webhook processing failed' }); + } +}); + +export default router; diff --git a/apps/api/src/routes/projects.ts b/apps/api/src/routes/projects.ts index b40ce30..b83daad 100644 --- a/apps/api/src/routes/projects.ts +++ b/apps/api/src/routes/projects.ts @@ -6,6 +6,7 @@ import { logger } from '@codehost/logger'; import { z } from 'zod'; import { BuilderService } from '../services/builder.js'; import { RunnerService } from '../services/runner.js'; +import { RESOURCE_TIERS } from '../config/tiers.js'; const router = Router(); @@ -14,20 +15,22 @@ router.use(requireAuth); const createProjectSchema = z.object({ name: z.string().min(3).max(50).regex(/^[a-z0-9-]+$/, 'Name can only contain lowercase letters, numbers, and dashes'), + tier: z.enum(['free', 'basic', 'pro', 'business']).default('free'), }); router.post('/', async (req: AuthRequest, res) => { try { - const { name } = createProjectSchema.parse(req.body); + const { name, tier } = createProjectSchema.parse(req.body); const userId = req.user!.id; + const tierConfig = RESOURCE_TIERS[tier]; - // Check free tier limit (1 project) + // Check project limit for the requested tier const projectCount = await prisma.project.count({ where: { userId } }); - if (projectCount >= 1) { - return res.status(403).json({ error: 'Free tier limit reached (Max 1 project)' }); + if (projectCount >= tierConfig.maxProjects) { + return res.status(403).json({ error: `Project limit reached for ${tierConfig.label} tier (Max ${tierConfig.maxProjects} project${tierConfig.maxProjects > 1 ? 's' : ''})` }); } // Check if name is taken @@ -39,11 +42,35 @@ router.post('/', async (req: AuthRequest, res) => { return res.status(400).json({ error: 'Project name already taken across the platform' }); } + // For paid tiers, check and deduct credits + if (tier !== 'free' && tierConfig.creditsPerMonth > 0) { + const wallet = await prisma.wallet.findUnique({ where: { userId } }); + if (!wallet || wallet.balance < tierConfig.creditsPerMonth) { + return res.status(402).json({ error: `Insufficient credits. ${tierConfig.label} tier requires ${tierConfig.creditsPerMonth} credits/month. Please top up your wallet.` }); + } + + await prisma.$transaction(async (tx) => { + await tx.wallet.update({ + where: { id: wallet.id }, + data: { balance: { decrement: tierConfig.creditsPerMonth } }, + }); + await tx.transaction.create({ + data: { + walletId: wallet.id, + amount: -tierConfig.creditsPerMonth, + type: 'tier_charge', + description: `Initial charge for ${name} (${tierConfig.label} tier)`, + }, + }); + }); + } + const project = await prisma.project.create({ data: { name, userId, status: 'idle', + tier, } }); @@ -289,52 +316,51 @@ router.put('/:id', async (req: AuthRequest, res) => { return res.status(404).json({ error: 'Project not found' }); } - const { name, buildCommand, startCommand, dockerfileOverride, envVars } = req.body; + const { name, buildCommand, startCommand, dockerfileOverride, envVars, tier } = req.body; - // Handle Rename (Subdomain Change) + // Handle Rename (Subdomain Change) logic (omitted for brevity in search but included in replacement) if (name && name.toLowerCase() !== project.name.toLowerCase()) { const nameLower = name.toLowerCase().trim(); - - // Validation if (!/^[a-z0-9-]+$/.test(nameLower) || nameLower.length < 3 || nameLower.length > 50) { return res.status(400).json({ error: 'Invalid name format' }); } - - const existingProject = await prisma.project.findFirst({ - where: { name: nameLower } - }); - + const existingProject = await prisma.project.findFirst({ where: { name: nameLower } }); if (existingProject) { return res.status(400).json({ error: 'Subdomain already taken across the platform' }); } + await prisma.project.update({ where: { id: project.id }, data: { name: nameLower } }); + } - await prisma.project.update({ - where: { id: project.id }, - data: { name: nameLower } - }); - - logger.info(`Project name updated for ${project.id}: ${project.name} -> ${nameLower}`); + // Handle tier change + let newTier = project.tier; + if (tier && tier !== project.tier && RESOURCE_TIERS[tier]) { + const newTierConfig = RESOURCE_TIERS[tier]; + const oldTierConfig = RESOURCE_TIERS[project.tier] || RESOURCE_TIERS['free']; + + if (tier !== 'free' && newTierConfig.creditsPerMonth > oldTierConfig.creditsPerMonth) { + const diff = newTierConfig.creditsPerMonth - oldTierConfig.creditsPerMonth; + const wallet = await prisma.wallet.findUnique({ where: { userId: req.user!.id } }); + if (!wallet || wallet.balance < diff) { + return res.status(402).json({ error: `Insufficient credits. Upgrade requires ${diff} additional credits.` }); + } - // Auto-redeploy if the project has a running container so the new subdomain takes effect - if (project.status === 'running' || project.containerId) { - const lastDeployment = await prisma.deployment.findFirst({ - where: { projectId: project.id, status: 'success' }, - orderBy: { createdAt: 'desc' } + await prisma.$transaction(async (tx) => { + await tx.wallet.update({ + where: { id: wallet.id }, + data: { balance: { decrement: diff } }, + }); + await tx.transaction.create({ + data: { + walletId: wallet.id, + amount: -diff, + type: 'tier_charge', + description: `Tier upgrade for ${project.name}: ${oldTierConfig.label} → ${newTierConfig.label}`, + projectId: project.id, + }, + }); }); - - if (lastDeployment) { - const imageName = `codehost-project-${project.id}:${lastDeployment.id}`; - // Fire-and-forget: restart container with updated Traefik labels - void (async () => { - try { - await RunnerService.startContainer(project.id, lastDeployment.id, imageName); - logger.info(`Auto-restarted container for project ${project.id} after subdomain change`); - } catch (err) { - logger.error({ error: err }, `Failed to auto-restart container after rename for ${project.id}`); - } - })(); - } } + newTier = tier; } const updated = await prisma.project.update({ @@ -344,10 +370,37 @@ router.put('/:id', async (req: AuthRequest, res) => { startCommand: startCommand ?? project.startCommand, dockerfileOverride: dockerfileOverride ?? project.dockerfileOverride, envVars: envVars ?? project.envVars, + tier: newTier, } }); - res.json({ project: updated, restarting: !!(name && name.toLowerCase() !== project.name.toLowerCase() && (project.status === 'running' || project.containerId)) }); + // Auto-restart if name or tier changed and project is running + const nameChanged = name && name.toLowerCase() !== project.name.toLowerCase(); + const tierChanged = tier && tier !== project.tier; + + if ((nameChanged || tierChanged) && (project.status === 'running' || project.containerId)) { + const lastDeployment = await prisma.deployment.findFirst({ + where: { projectId: project.id, status: 'success' }, + orderBy: { createdAt: 'desc' } + }); + + if (lastDeployment) { + const imageName = `codehost-project-${project.id}:${lastDeployment.id}`; + void (async () => { + try { + await RunnerService.startContainer(project.id, lastDeployment.id, imageName); + logger.info(`Auto-restarted container for project ${project.id} after settings change (name/tier)`); + } catch (err) { + logger.error({ error: err }, `Failed to auto-restart container after settings change for ${project.id}`); + } + })(); + } + } + + res.json({ + project: updated, + restarting: !!((nameChanged || tierChanged) && (project.status === 'running' || project.containerId)) + }); } catch (error) { logger.error({ error }, 'Update project error'); res.status(500).json({ error: 'Failed to update project settings' }); diff --git a/apps/api/src/services/razorpay.ts b/apps/api/src/services/razorpay.ts new file mode 100644 index 0000000..5148bc7 --- /dev/null +++ b/apps/api/src/services/razorpay.ts @@ -0,0 +1,50 @@ +import Razorpay from 'razorpay'; +import crypto from 'crypto'; +import { env } from '@codehost/config'; +import { logger } from '@codehost/logger'; + +let razorpayInstance: Razorpay | null = null; + +function getRazorpay(): Razorpay { + if (!razorpayInstance) { + if (!env.RAZORPAY_KEY_ID || !env.RAZORPAY_KEY_SECRET) { + throw new Error('Razorpay credentials not configured'); + } + razorpayInstance = new Razorpay({ + key_id: env.RAZORPAY_KEY_ID, + key_secret: env.RAZORPAY_KEY_SECRET, + }); + } + return razorpayInstance; +} + +export async function createOrder(amountPaise: number, receipt: string, notes: Record = {}) { + const razorpay = getRazorpay(); + const order = await razorpay.orders.create({ + amount: amountPaise, + currency: 'INR', + receipt, + notes, + }); + logger.info(`Razorpay order created: ${order.id} for ${amountPaise} paise`); + return order; +} + +export function verifySignature(orderId: string, paymentId: string, signature: string): boolean { + if (!env.RAZORPAY_KEY_SECRET) return false; + const body = `${orderId}|${paymentId}`; + const expectedSignature = crypto + .createHmac('sha256', env.RAZORPAY_KEY_SECRET) + .update(body) + .digest('hex'); + return expectedSignature === signature; +} + +export function verifyWebhookSignature(body: string, signature: string): boolean { + if (!env.RAZORPAY_WEBHOOK_SECRET) return false; + const expectedSignature = crypto + .createHmac('sha256', env.RAZORPAY_WEBHOOK_SECRET) + .update(body) + .digest('hex'); + return expectedSignature === signature; +} diff --git a/apps/api/src/services/runner.ts b/apps/api/src/services/runner.ts index 70cd2d5..a442a91 100644 --- a/apps/api/src/services/runner.ts +++ b/apps/api/src/services/runner.ts @@ -5,6 +5,7 @@ import { logger } from '@codehost/logger'; import { prisma } from '@codehost/database'; import { env } from '@codehost/config'; import { io } from '../index.js'; +import { RESOURCE_TIERS } from '../config/tiers.js'; export class RunnerService { public static async startContainer(projectId: string, deploymentId: string, imageName: string) { @@ -44,13 +45,19 @@ export class RunnerService { // Priority: Dockerfile EXPOSE → nginx.conf listen → image ExposedPorts → default 80 const containerPort = await this.detectPort(projectId, imageName); + // Look up tier-based resource limits + const tierConfig = RESOURCE_TIERS[project?.tier || 'free'] || RESOURCE_TIERS['free']; + const memoryLimit = tierConfig.memory; + const nanoCpus = tierConfig.cpus * 1e9; + let container = await docker.createContainer({ Image: imageName, name: containerName, HostConfig: { PublishAllPorts: true, - Memory: env.MEMORY_LIMIT || 128 * 1024 * 1024, - MemorySwap: env.MEMORY_LIMIT || 128 * 1024 * 1024, + Memory: memoryLimit, + MemorySwap: memoryLimit, + NanoCpus: nanoCpus, NetworkMode: 'docker_default', // Connect to the Brain's network }, Labels: { @@ -124,8 +131,9 @@ export class RunnerService { name: containerName, HostConfig: { PublishAllPorts: true, - Memory: env.MEMORY_LIMIT || 128 * 1024 * 1024, - MemorySwap: env.MEMORY_LIMIT || 128 * 1024 * 1024, + Memory: memoryLimit, + MemorySwap: memoryLimit, + NanoCpus: nanoCpus, NetworkMode: 'docker_default', }, Labels: { 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..17c5094 --- /dev/null +++ b/apps/web/src/app/dashboard/billing/page.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { fetchApi, API_URL } from '@/lib/api'; +import PanelLayout from '@/components/PanelLayout'; +import { CreditCard, Wallet, ArrowUpRight, ArrowDownRight, Loader2, Zap, Package } from 'lucide-react'; + +interface WalletData { + id: string; + balance: number; +} + +interface Transaction { + id: string; + amount: number; + type: string; + description: string | null; + createdAt: string; +} + +interface CreditPackage { + credits: number; + priceInr: number; + label: string; + savings: string | null; +} + +declare global { + interface Window { + Razorpay: any; + } +} + +export default function BillingPage() { + const router = useRouter(); + const [user, setUser] = useState<{ email: string; username: string; role: string } | null>(null); + const [wallet, setWallet] = useState(null); + const [transactions, setTransactions] = useState([]); + const [packages, setPackages] = useState([]); + const [loading, setLoading] = useState(true); + const [purchasing, setPurchasing] = useState(null); + + useEffect(() => { + // Load Razorpay checkout script + const script = document.createElement('script'); + script.src = 'https://checkout.razorpay.com/v1/checkout.js'; + script.async = true; + document.body.appendChild(script); + return () => { document.body.removeChild(script); }; + }, []); + + useEffect(() => { + Promise.all([ + fetchApi('/auth/me'), + fetchApi('/billing/wallet'), + fetchApi('/billing/transactions'), + fetchApi('/billing/tiers'), + ]) + .then(([authRes, walletRes, txRes, tierRes]) => { + setUser(authRes.user); + setWallet(walletRes.wallet); + setTransactions(txRes.transactions); + setPackages(tierRes.creditPackages); + }) + .catch(() => router.push('/login')) + .finally(() => setLoading(false)); + }, [router]); + + const handlePurchase = async (pkg: CreditPackage) => { + setPurchasing(pkg.credits); + try { + const orderData = await fetchApi('/billing/purchase', { + method: 'POST', + body: JSON.stringify({ credits: pkg.credits }), + }); + + const options = { + key: process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID, + amount: orderData.amount, + currency: orderData.currency, + name: 'CodeHost', + description: `${pkg.credits} Credits`, + order_id: orderData.orderId, + handler: async (response: any) => { + try { + await fetchApi('/billing/verify', { + method: 'POST', + body: JSON.stringify({ + razorpayOrderId: response.razorpay_order_id, + razorpayPaymentId: response.razorpay_payment_id, + razorpaySignature: response.razorpay_signature, + credits: pkg.credits, + amount: orderData.amount, + }), + }); + // Refresh wallet and transactions + const [walletRes, txRes] = await Promise.all([ + fetchApi('/billing/wallet'), + fetchApi('/billing/transactions'), + ]); + setWallet(walletRes.wallet); + setTransactions(txRes.transactions); + } catch (err) { + alert('Payment verification failed. Please contact support.'); + } + }, + prefill: { + email: user?.email, + }, + theme: { + color: '#2563EB', + }, + }; + + const rzp = new window.Razorpay(options); + rzp.open(); + } catch (err: any) { + alert(err.message || 'Failed to initiate payment'); + } finally { + setPurchasing(null); + } + }; + + if (loading) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+ {/* Wallet Balance */} +
+
+
+

Wallet Balance

+
+ {wallet?.balance || 0} + credits +
+

1 credit = ₹2

+
+
+ +
+
+
+ + {/* Buy Credits */} +
+

+ + Buy Credits +

+
+ {packages.map((pkg) => ( +
+
+

{pkg.credits}

+ +
+

Credits

+
+ ₹{pkg.priceInr} +
+ {pkg.savings && ( + + {pkg.savings} + + )} + +
+ ))} +
+
+ + {/* Transaction History */} +
+

Transaction History

+
+ {transactions.length === 0 ? ( +
+ No transactions yet. Purchase credits to get started. +
+ ) : ( + + + + + + + + + + + {transactions.map((tx) => ( + + + + + + + ))} + +
DateTypeDescriptionAmount
+ {new Date(tx.createdAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })} + + + {tx.type.replace('_', ' ')} + + {tx.description || '-'} + 0 ? 'text-emerald-600' : 'text-red-500'}`}> + {tx.amount > 0 ? : } + {tx.amount > 0 ? '+' : ''}{tx.amount} + +
+ )} +
+
+
+
+ ); +} diff --git a/apps/web/src/app/dashboard/new/page.tsx b/apps/web/src/app/dashboard/new/page.tsx index 67a42e7..3f17f9a 100644 --- a/apps/web/src/app/dashboard/new/page.tsx +++ b/apps/web/src/app/dashboard/new/page.tsx @@ -4,30 +4,59 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { fetchApi } from '@/lib/api'; import PanelLayout from '@/components/PanelLayout'; -import { Check, Code, Settings, Loader2 } from 'lucide-react'; +import { Check, Code, Settings, Loader2, Cpu, HardDrive, Database, Layers } from 'lucide-react'; + +interface TierInfo { + id: string; + label: string; + memory: number; + cpus: number; + storage: number; + creditsPerMonth: number; + maxProjects: number; + priceInr: number; +} export default function NewProject() { const router = useRouter(); const [step, setStep] = useState(1); const [name, setName] = useState(''); + const [tier, setTier] = useState('free'); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [user, setUser] = useState<{ email: string; username: string; role: string } | null>(null); + const [tiers, setTiers] = useState([]); + const [walletBalance, setWalletBalance] = useState(0); useEffect(() => { - fetchApi('/auth/me') - .then(res => setUser(res.user)) + Promise.all([ + fetchApi('/auth/me'), + fetchApi('/billing/tiers'), + fetchApi('/billing/wallet'), + ]) + .then(([authRes, tierRes, walletRes]) => { + setUser(authRes.user); + setTiers(tierRes.tiers); + setWalletBalance(walletRes.wallet.balance); + }) .catch(() => router.push('/login')); }, [router]); + const formatBytes = (bytes: number) => { + if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(0)}GB`; + return `${(bytes / (1024 * 1024)).toFixed(0)}MB`; + }; + + const selectedTier = tiers.find(t => t.id === tier); + const handleCreate = async () => { setLoading(true); setError(''); - + try { const data = await fetchApi('/projects', { method: 'POST', - body: JSON.stringify({ name: name.toLowerCase() }), + body: JSON.stringify({ name: name.toLowerCase(), tier }), }); router.push(`/dashboard/project/${data.project.id}`); } catch (err: any) { @@ -38,18 +67,19 @@ export default function NewProject() { return ( -
+
{/* Wizard Steps */} - {/* Step 1 Content */} + {/* Step 1: Name */} {step === 1 && (
@@ -78,7 +108,7 @@ export default function NewProject() { {error}
)} - +
@@ -107,11 +137,103 @@ export default function NewProject() {
+
+
+ )} + + {/* Step 2: Tier Selection */} + {step === 2 && ( +
+
+

Choose a Plan

+

+ Select resources for your project. Wallet balance: {walletBalance} credits +

+
+ + {error && ( +
+ {error} +
+ )} + +
+ {tiers.map((t) => { + const isSelected = tier === t.id; + const canAfford = t.creditsPerMonth === 0 || walletBalance >= t.creditsPerMonth; + return ( + + ); + })} +
+ +
+ +
diff --git a/apps/web/src/app/dashboard/project/[id]/page.tsx b/apps/web/src/app/dashboard/project/[id]/page.tsx index f378e1e..d3b796b 100644 --- a/apps/web/src/app/dashboard/project/[id]/page.tsx +++ b/apps/web/src/app/dashboard/project/[id]/page.tsx @@ -39,6 +39,7 @@ interface Project { id: string; name: string; status: string; + tier?: string; port?: number; containerId?: string; user?: { email: string; username: string }; @@ -51,6 +52,17 @@ interface Project { repoSubdir?: string; } +interface TierInfo { + id: string; + label: string; + memory: number; + cpus: number; + storage: number; + creditsPerMonth: number; + maxProjects: number; + priceInr: number; +} + interface Deployment { id: string; status: string; @@ -95,6 +107,11 @@ export default function ProjectDetail({ params: paramsPromise }: { params: Promi const [logs, setLogs] = useState<{message: string; timestamp: string; type: string}[]>([]); const [stats, setStats] = useState<{cpu: number; memory: {usage: number; limit: number; percent: number}} | null>(null); + // Tier state + const [tiers, setTiers] = useState([]); + const [selectedTier, setSelectedTier] = useState(''); + const [tierLoading, setTierLoading] = useState(false); + // Subdomain change state const [newName, setNewName] = useState(''); const [nameAvailable, setNameAvailable] = useState(null); @@ -117,15 +134,18 @@ export default function ProjectDetail({ params: paramsPromise }: { params: Promi const fetchProjectData = async () => { try { - const [meRes, projRes, depRes] = await Promise.all([ + const [meRes, projRes, depRes, tierRes] = await Promise.all([ fetchApi('/auth/me'), fetchApi(`/projects/${params.id}`), - fetchApi(`/deployments/${params.id}`) + fetchApi(`/deployments/${params.id}`), + fetchApi('/billing/tiers'), ]); setUser(meRes.user); setProject(projRes.project); setDeployments(depRes.deployments); + setTiers(tierRes.tiers); + if (!selectedTier) setSelectedTier(projRes.project.tier || 'free'); setSettings({ buildCommand: projRes.project.buildCommand || '', @@ -840,6 +860,65 @@ export default function ProjectDetail({ params: paramsPromise }: { params: Promi
+ {/* Resource Tier */} +
+

+ + Resource Tier +

+

Current: {tiers.find(t => t.id === project.tier)?.label || 'Free'}

+
+ {tiers.map((t) => { + const isCurrent = t.id === project.tier; + const isSelected = t.id === selectedTier; + return ( + + ); + })} +
+ {selectedTier !== project.tier && ( + + )} +
+

General Information

diff --git a/apps/web/src/app/docs/page.tsx b/apps/web/src/app/docs/page.tsx index 621e728..6a47acf 100644 --- a/apps/web/src/app/docs/page.tsx +++ b/apps/web/src/app/docs/page.tsx @@ -88,6 +88,7 @@ export default function DocsPage() {

Help

+ Billing & Pricing Deployment Status Troubleshooting Limits & Quotas @@ -649,6 +650,56 @@ app.listen(PORT, () => console.log(\`Server running on port \${PORT}\`));`} + {/* Billing & Pricing */} +
+
+
+ +
+

Billing & Pricing

+
+

+ CodeHost uses a prepaid credits model. You buy credits in packages, and then assign a resource tier to each of your projects. 1 credit = ₹2. +

+ +

How it Works

+
+
+ 1. Buy Credits +

Purchase credit packages via Razorpay (supports UPI, Cards, Netbanking). Credits are added to your account wallet instantly.

+
+
+ 2. Pick a Tier +

When creating a project or in project settings, chose a resource tier (Free, Basic, Pro, or Business). Each tier has different RAM, CPU, and storage limits.

+
+
+ 3. Monthly Charge +

Credits are deducted from your wallet monthly for each active paid project. If your balance hits zero, your project will be automatically stopped.

+
+
+ +

Credit Packages

+
+
+

Starter

+

100 Credits

+

₹200

+
+
+
Save 17%
+

Growth

+

300 Credits

+

₹500

+
+
+
Save 25%
+

Team

+

600 Credits

+

₹900

+
+
+
+ {/* Deployment Status */}
@@ -775,33 +826,51 @@ app.listen(PORT, () => console.log(\`Server running on port \${PORT}\`));`} Resource - Free Tier + Free + Basic + Pro + Business - Projects - 1 active project + Max Projects + 1 + 3 + 5 + 10 Memory (RAM) - 128MB per container + 128MB + 256MB + 512MB + 1GB - Upload Size - 50MB max per zip + CPU Cores + 0.5 + 1.0 + 2.0 + 4.0 - Deployment History - Last 10 deployments stored + Storage + 1GB + 2GB + 5GB + 10GB - Subdomain - Shared CodeHost subdomain + Credits/Month + 0 + 50 + 150 + 400 - SSL - Automatic via Traefik + Upload Size + 50MB max per zip diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 6eebfe9..dce0868 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -429,14 +429,15 @@ export default function Home() {

Transparent Pricing

Choose your scale.

+

Prepaid credits model. Buy credits, pick a tier for each project. 1 credit = ₹2.

-
+
{/* Free Tier */}
-

Student Free

-

For individuals

+

Free

+

For students

₹0 /forever @@ -448,15 +449,15 @@ export default function Home() {
  • - 128MB RAM + 128MB RAM · 0.5 CPU
  • - CodeHost Shared Subdomain + 1GB Storage
  • - Live Build Console + Free Subdomain
  • @@ -465,34 +466,67 @@ export default function Home() {
    + {/* Basic Tier */} +
    +
    +

    Basic

    +

    For hobbyists

    +
    + ₹100 + /month +
    +
      +
    • + + 3 Active Projects +
    • +
    • + + 256MB RAM · 1 CPU +
    • +
    • + + 2GB Storage +
    • +
    • + + 50 credits/month +
    • +
    +
    + + Get Started + +
    + {/* Pro Tier */}
    Best Deal
    -

    Power User

    +

    Pro

    For serious builders

    - ₹50 + ₹300 /month
    • - Unlimted Projects + 5 Active Projects
    • - 1GB RAM per container + 512MB RAM · 2 CPUs
    • - Custom Domain Mapping + 5GB Storage
    • - Always-On Hosting + 150 credits/month
    • @@ -500,42 +534,42 @@ export default function Home() {
    - + + Get Started +
    - {/* Teams Tier */} + {/* Business Tier */}
    -

    Teams

    -

    Collaborative scaling

    +

    Business

    +

    For teams

    - ₹100 + ₹800 /month
    • - Shared Workspaces + 10 Active Projects
    • - RBAC Permissions + 1GB RAM · 4 CPUs
    • - Dedicated VPS Nodes + 10GB Storage
    • - Audit Logs + 400 credits/month
    - + + Get Started +
    diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index f0abd70..b5ba549 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -47,6 +47,7 @@ export default function PrivacyPage() {
  • Usage data: Pages visited, features used, deployment frequency, and session duration.
  • Build & runtime logs: Console output generated during project builds and execution.
  • Device information: Browser type, operating system, and IP address for security and analytics.
  • +
  • Payment Information: When you purchase credits, our third-party payment processor (Razorpay) collects your payment details. We only receive and store transaction metadata, including Razorpay Order IDs, Payment IDs, and the amount of credits purchased. We do not store full credit card numbers or other sensitive financial credentials.
  • @@ -99,6 +100,7 @@ export default function PrivacyPage() {
  • Redis: Used for caching, session management, and real-time event handling.
  • Google OAuth: If you choose to sign in with Google, we exchange an authorization code with Google's servers to receive your name, email address, and Google user ID. We do not store your Google password or access token beyond the initial sign-in.
  • GitHub OAuth: If you choose to sign in with GitHub, we exchange an authorization code with GitHub's servers to receive your name, email address, and GitHub user ID. We do not store your GitHub password or access token beyond the initial sign-in.
  • +
  • Razorpay (Payments): We use Razorpay to process your credit purchases. Razorpay collects your payment information directly. You can find their privacy policy at razorpay.com/privacy.
  • SMTP (Email): We use an email delivery service to send verification emails. Only your email address and the verification link are transmitted.
  • diff --git a/apps/web/src/app/terms/page.tsx b/apps/web/src/app/terms/page.tsx index 61887e1..6cfb398 100644 --- a/apps/web/src/app/terms/page.tsx +++ b/apps/web/src/app/terms/page.tsx @@ -98,32 +98,87 @@ export default function TermsPage() {

    - {/* 5 */} + {/* 6 */} +
    +

    6. Payments, Credits, and Tiers

    +

    + CodeHost operates on a prepaid credit model ("Credits"). By using paid resource tiers, you agree to the following: +

    +
      +
    • Credit Value: 1 Credit is currently valued at ₹2 INR. This value is subject to change with 30 days' notice.
    • +
    • Resource Tiers: Each project is assigned a tier (Free, Basic, Pro, or Business). Paid tiers trigger a monthly deduction from your wallet balance.
    • +
    • Initial Charge: When you create a project on a paid tier or upgrade an existing project, the first month's credits are deducted immediately.
    • +
    • Recurring Charges: A recurring monthly charge is applied exactly 30 days after the last successful charge for each project.
    • +
    • Tier Changes: Upgrading a tier requires a payment of the credit difference. Downgrading a tier takes effect on the next billing cycle; no partial refunds are provided for current-month downgrades.
    • +
    +
    + + {/* 7 */} +
    +

    7. Razorpay & Payment Processing

    +

    + We use Razorpay as our third-party payment processor. By purchasing credits, you also agree to Razorpay's terms and conditions. +

    +
      +
    • CodeHost does not store your full credit card details, UPI PINs, or net-banking credentials. This data is handled exclusively by Razorpay.
    • +
    • We only store payment metadata (Razorpay Order ID, Payment ID) to verify and record your credit purchases.
    • +
    • You are responsible for any fees charged by your bank or payment provider during the transaction.
    • +
    +
    + + {/* 8 */}
    -

    6. Account Suspension & Termination

    +

    8. Refunds and Expiration

    +

    + All credit purchases are final and non-refundable. +

    +
      +
    • Credits do not expire as long as your account remains active.
    • +
    • If you delete a project mid-month, no pro-rated refund of credits is provided for the remaining days of that cycle.
    • +
    • Refunds are only issued in exceptional cases where a technical error prevented the delivery of purchased credits, or as required by law.
    • +
    +
    + + {/* 9 */} +
    +

    9. Insufficient Balance & Project Suspension

    +

    + It is your responsibility to maintain a sufficient credit balance for your paid projects. +

    +
      +
    • If your wallet balance is insufficient to cover a project's recurring monthly charge, the project container will be automatically stopped.
    • +
    • Your project data and configuration will remain intact. You can restart the project at any time after topping up your wallet balance.
    • +
    • CodeHost is not liable for any downtime, data loss, or business impact caused by project suspension due to insufficient balance.
    • +
    +
    + + {/* 10 */} +
    +

    10. Account Suspension & Termination

    We may suspend or permanently terminate your account if you:

    • Violate these Terms of Service or the Acceptable Use policy.
    • Attempt to exploit, attack, or reverse-engineer the platform.
    • Use excessive resources that degrade service for other users.
    • -
    • Create multiple accounts to circumvent platform limits.
    • +
    • Create multiple accounts to circumvent platform limits or free-tier quotas.
    • +
    • Initiate unauthorized chargebacks or payment disputes with Razorpay.

    - Upon termination, all your deployed projects will be stopped and your data will be deleted within 30 days. You will be notified via email before any permanent action is taken, except in cases of severe policy violations. + Upon termination, all your deployed projects will be stopped and your data will be deleted within 30 days. Unused credits are non-refundable upon account termination.

    - {/* 6 */} + {/* 11 */}
    -

    7. Limitation of Liability

    +

    11. Limitation of Liability

    To the maximum extent permitted by law, CodeHost and its operator shall not be liable for any indirect, incidental, special, consequential, or punitive damages — including but not limited to loss of data, revenue, or profits — arising from your use of the platform. Our total liability for any claim shall not exceed the amount you have paid to CodeHost in the 12 months preceding the claim. This limitation applies whether the claim is based on warranty, contract, tort, or any other legal theory.

    - {/* 7 */} + {/* 12 */}
    -

    8. Changes to Terms

    +

    12. Changes to Terms

    We may update these Terms of Service from time to time. When we do, we will revise the “Last updated” date at the top of this page. Continued use of the platform after changes are posted constitutes your acceptance of the revised terms. For significant changes, we may notify you via email or a banner on the dashboard.

    diff --git a/apps/web/src/components/PanelLayout.tsx b/apps/web/src/components/PanelLayout.tsx index 53683d0..43887b0 100644 --- a/apps/web/src/components/PanelLayout.tsx +++ b/apps/web/src/components/PanelLayout.tsx @@ -38,7 +38,7 @@ export default function PanelLayout({ children, user, projectName }: PanelLayout const navItems = [ { name: 'Dashboard', icon: LayoutDashboard, href: '/dashboard' }, { name: 'Explore Templates', icon: Compass, href: '#', disabled: true }, - { name: 'Billing', icon: CreditCard, href: '#', disabled: true }, + { name: 'Billing', icon: CreditCard, href: '/dashboard/billing' }, { name: 'Project Settings', icon: Settings, href: '#', disabled: true }, ]; diff --git a/package-lock.json b/package-lock.json index 38b2fd4..6b7b040 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "jsonwebtoken": "^9.0.3", "multer": "^2.0.2", "nodemailer": "^8.0.3", + "razorpay": "^2.9.6", "socket.io": "^4.8.3", "tar-fs": "^3.1.1" }, @@ -6200,6 +6201,12 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -6235,6 +6242,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -6888,6 +6906,18 @@ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -7271,6 +7301,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -7678,7 +7717,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8602,6 +8640,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -8618,6 +8676,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -9070,7 +9144,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -12021,6 +12094,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -12184,6 +12263,15 @@ "node": ">= 0.8" } }, + "node_modules/razorpay": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/razorpay/-/razorpay-2.9.6.tgz", + "integrity": "sha512-zsHAQzd6e1Cc6BNoCNZQaf65ElL6O6yw0wulxmoG5VQDr363fZC90Mp1V5EktVzG45yPyNomNXWlf4cQ3622gQ==", + "license": "MIT", + "dependencies": { + "axios": "^1.6.8" + } + }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", diff --git a/packages/config/index.ts b/packages/config/index.ts index 7b89b91..9b06512 100644 --- a/packages/config/index.ts +++ b/packages/config/index.ts @@ -28,6 +28,9 @@ export const envSchema = z.object({ SMTP_PASS: z.string().optional(), APP_URL: z.string().default('http://localhost:3000'), API_URL: z.string().default('http://localhost:4000'), + RAZORPAY_KEY_ID: z.string().optional(), + RAZORPAY_KEY_SECRET: z.string().optional(), + RAZORPAY_WEBHOOK_SECRET: z.string().optional(), }); export const env = envSchema.parse(process.env); diff --git a/packages/database/prisma/migrations/20260322000000_add_billing/migration.sql b/packages/database/prisma/migrations/20260322000000_add_billing/migration.sql new file mode 100644 index 0000000..49df91d --- /dev/null +++ b/packages/database/prisma/migrations/20260322000000_add_billing/migration.sql @@ -0,0 +1,37 @@ +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "tier" TEXT NOT NULL DEFAULT 'free'; + +-- CreateTable +CREATE TABLE "Wallet" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "balance" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Wallet_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Transaction" ( + "id" TEXT NOT NULL, + "walletId" TEXT NOT NULL, + "amount" INTEGER NOT NULL, + "type" TEXT NOT NULL, + "description" TEXT, + "razorpayOrderId" TEXT, + "razorpayPaymentId" TEXT, + "projectId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Transaction_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Wallet_userId_key" ON "Wallet"("userId"); + +-- AddForeignKey +ALTER TABLE "Wallet" ADD CONSTRAINT "Wallet_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index aa2302d..6a47212 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -23,6 +23,7 @@ model User { verificationTokenExpiry DateTime? projects Project[] sessions Session[] + wallet Wallet? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -44,6 +45,7 @@ model Project { userId String user User @relation(fields: [userId], references: [id]) status String @default("idle") // idle, building, running, failed, stopped + tier String @default("free") // free, basic, pro, business containerId String? port Int? buildCommand String? @default("") @@ -58,6 +60,29 @@ model Project { updatedAt DateTime @updatedAt } +model Wallet { + id String @id @default(uuid()) + userId String @unique + user User @relation(fields: [userId], references: [id]) + balance Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + transactions Transaction[] +} + +model Transaction { + id String @id @default(uuid()) + walletId String + wallet Wallet @relation(fields: [walletId], references: [id]) + amount Int + type String // purchase, tier_charge, refund, admin_grant + description String? + razorpayOrderId String? + razorpayPaymentId String? + projectId String? + createdAt DateTime @default(now()) +} + model Deployment { id String @id @default(uuid()) projectId String