From a64b8dbecba5edd4b6da434e0e7118292e2c8b1c Mon Sep 17 00:00:00 2001 From: IT22056320 Date: Sun, 5 Apr 2026 09:19:02 +0530 Subject: [PATCH] feat: implement Stripe integration for subscription management and pricing page --- Backend/.env.example | 6 + Backend/package-lock.json | 24 +- Backend/package.json | 3 +- Backend/src/app.module.ts | 4 + Backend/src/database/database.service.ts | 14 + Backend/src/main.ts | 2 +- .../src/stripe/stripe-webhook.controller.ts | 57 +++ Backend/src/stripe/stripe.module.ts | 7 + .../subscription/subscription.controller.ts | 31 ++ .../src/subscription/subscription.module.ts | 11 + .../src/subscription/subscription.service.ts | 143 +++++++ Backend/tsconfig.json | 1 + Frontend/.env | 4 +- Frontend/app/pricing/page.tsx | 379 ++++++++++++++++++ Frontend/components/sections/AnimatedNav.tsx | 6 + 15 files changed, 686 insertions(+), 6 deletions(-) create mode 100644 Backend/src/stripe/stripe-webhook.controller.ts create mode 100644 Backend/src/stripe/stripe.module.ts create mode 100644 Backend/src/subscription/subscription.controller.ts create mode 100644 Backend/src/subscription/subscription.module.ts create mode 100644 Backend/src/subscription/subscription.service.ts create mode 100644 Frontend/app/pricing/page.tsx diff --git a/Backend/.env.example b/Backend/.env.example index 3f25bce..571c945 100644 --- a/Backend/.env.example +++ b/Backend/.env.example @@ -10,3 +10,9 @@ POSTGRES_PASSWORD= AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net AZURE_STORAGE_CONTAINER_NAME=springforge-plugins + +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... +STRIPE_PRICE_ID=price_... +STRIPE_SUCCESS_URL=https://www.springforge.dev/admin?payment=success +STRIPE_CANCEL_URL=https://www.springforge.dev/admin?payment=cancelled diff --git a/Backend/package-lock.json b/Backend/package-lock.json index 14c79d1..8676f28 100644 --- a/Backend/package-lock.json +++ b/Backend/package-lock.json @@ -18,7 +18,8 @@ "multer": "^1.4.5-lts.1", "pg": "^8.11.3", "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "stripe": "^22.0.0" }, "devDependencies": { "@nestjs/cli": "^10.0.0", @@ -1037,7 +1038,7 @@ "version": "20.19.35", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -4606,6 +4607,23 @@ "node": ">=4" } }, + "node_modules/stripe": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.0.0.tgz", + "integrity": "sha512-q1UgXXpSfZCmkyzZEh3vFEWT7+ajuaFGqaP9Tsi2NMtwlkigIWNr+KBIUQqtNeNEsreDKgdn+BP5HRW9JDj22Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/strnum": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", @@ -4938,7 +4956,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/universalify": { diff --git a/Backend/package.json b/Backend/package.json index c01acc8..ab8f079 100644 --- a/Backend/package.json +++ b/Backend/package.json @@ -21,7 +21,8 @@ "multer": "^1.4.5-lts.1", "pg": "^8.11.3", "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "stripe": "^22.0.0" }, "devDependencies": { "@nestjs/cli": "^10.0.0", diff --git a/Backend/src/app.module.ts b/Backend/src/app.module.ts index 82f0c28..4486ded 100644 --- a/Backend/src/app.module.ts +++ b/Backend/src/app.module.ts @@ -5,6 +5,8 @@ import { AdminModule } from './admin/admin.module'; import { AuthModule } from './auth/auth.module'; import { FeedbackModule } from './feedback/feedback.module'; import { HealthModule } from './health/health.module'; +import { SubscriptionModule } from './subscription/subscription.module'; +import { StripeModule } from './stripe/stripe.module'; @Module({ imports: [ @@ -14,6 +16,8 @@ import { HealthModule } from './health/health.module'; AdminModule, FeedbackModule, HealthModule, + SubscriptionModule, + StripeModule, ], }) export class AppModule {} diff --git a/Backend/src/database/database.service.ts b/Backend/src/database/database.service.ts index 5f5b5b3..5160d78 100644 --- a/Backend/src/database/database.service.ts +++ b/Backend/src/database/database.service.ts @@ -106,6 +106,20 @@ export class DatabaseService implements OnModuleInit { version VARCHAR(50), downloaded_at TIMESTAMPTZ DEFAULT NOW() ); + + CREATE TABLE IF NOT EXISTS subscriptions ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tier VARCHAR(20) NOT NULL DEFAULT 'COMMUNITY', + stripe_customer_id VARCHAR(255), + stripe_price_id VARCHAR(255), + expires_at TIMESTAMPTZ, + requests_used INTEGER NOT NULL DEFAULT 0, + usage_reset_at TIMESTAMPTZ NOT NULL DEFAULT + (date_trunc('month', NOW()) + INTERVAL '1 month'), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id) + ); `); this.logger.log('Database schema ready'); } diff --git a/Backend/src/main.ts b/Backend/src/main.ts index 60ccac7..0290f7e 100644 --- a/Backend/src/main.ts +++ b/Backend/src/main.ts @@ -6,7 +6,7 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { rawBody: true }); app.enableCors({ origin: '*' }); app.setGlobalPrefix('api'); diff --git a/Backend/src/stripe/stripe-webhook.controller.ts b/Backend/src/stripe/stripe-webhook.controller.ts new file mode 100644 index 0000000..2a78a0b --- /dev/null +++ b/Backend/src/stripe/stripe-webhook.controller.ts @@ -0,0 +1,57 @@ +import { Controller, Post, Req, Res } from '@nestjs/common'; +import { Request, Response } from 'express'; +import Stripe = require('stripe'); +import { DatabaseService } from '../database/database.service'; + +@Controller('stripe') +export class StripeWebhookController { + private readonly stripe: ReturnType; + + constructor(private readonly db: DatabaseService) { + this.stripe = new (Stripe as any)(process.env.STRIPE_SECRET_KEY ?? ''); + } + + @Post('webhook') + async handleWebhook(@Req() req: Request, @Res() res: Response) { + const sig = req.headers['stripe-signature'] as string; + let event: { type: string; data: { object: any } }; + + try { + event = this.stripe.webhooks.constructEvent( + (req as any).rawBody, + sig, + process.env.STRIPE_WEBHOOK_SECRET ?? '', + ); + } catch { + return res.status(400).send('Bad webhook signature'); + } + + if (event.type === 'checkout.session.completed') { + const session = event.data.object; + const userId: string | null = session.client_reference_id; + if (userId) { + await this.db.query( + `UPDATE subscriptions + SET tier = 'ULTIMATE', + stripe_customer_id = $1, + stripe_price_id = $2, + expires_at = NULL + WHERE user_id = $3`, + [session.customer, session.subscription, Number.parseInt(userId)], + ); + } + } + + if (event.type === 'customer.subscription.deleted') { + const subscription = event.data.object; + await this.db.query( + `UPDATE subscriptions + SET tier = 'COMMUNITY', expires_at = NULL + WHERE stripe_customer_id = $1`, + [subscription.customer], + ); + } + + return res.json({ received: true }); + } +} diff --git a/Backend/src/stripe/stripe.module.ts b/Backend/src/stripe/stripe.module.ts new file mode 100644 index 0000000..4eadafd --- /dev/null +++ b/Backend/src/stripe/stripe.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { StripeWebhookController } from './stripe-webhook.controller'; + +@Module({ + controllers: [StripeWebhookController], +}) +export class StripeModule {} diff --git a/Backend/src/subscription/subscription.controller.ts b/Backend/src/subscription/subscription.controller.ts new file mode 100644 index 0000000..308c459 --- /dev/null +++ b/Backend/src/subscription/subscription.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; +import { SubscriptionService } from './subscription.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { Request } from 'express'; + +@Controller('subscription') +@UseGuards(JwtAuthGuard) +export class SubscriptionController { + constructor(private readonly subscriptionService: SubscriptionService) {} + + @Get('status') + getStatus(@Req() req: Request) { + return this.subscriptionService.getStatus((req as any).user.id); + } + + @Post('usage/increment') + incrementUsage(@Req() req: Request) { + return this.subscriptionService.incrementUsage((req as any).user.id); + } + + @Post('create-checkout-session') + createCheckout(@Req() req: Request) { + const user = (req as any).user; + return this.subscriptionService.createCheckoutSession(user.id, user.email); + } + + @Post('cancel') + cancelSubscription(@Req() req: Request) { + return this.subscriptionService.cancelSubscription((req as any).user.id); + } +} diff --git a/Backend/src/subscription/subscription.module.ts b/Backend/src/subscription/subscription.module.ts new file mode 100644 index 0000000..9a43d92 --- /dev/null +++ b/Backend/src/subscription/subscription.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { SubscriptionController } from './subscription.controller'; +import { SubscriptionService } from './subscription.service'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [AuthModule], + controllers: [SubscriptionController], + providers: [SubscriptionService], +}) +export class SubscriptionModule {} diff --git a/Backend/src/subscription/subscription.service.ts b/Backend/src/subscription/subscription.service.ts new file mode 100644 index 0000000..51d5735 --- /dev/null +++ b/Backend/src/subscription/subscription.service.ts @@ -0,0 +1,143 @@ +import { Injectable } from '@nestjs/common'; +import Stripe = require('stripe'); +import { DatabaseService } from '../database/database.service'; + +@Injectable() +export class SubscriptionService { + private readonly stripe: ReturnType; + + constructor(private readonly db: DatabaseService) { + this.stripe = new (Stripe as any)(process.env.STRIPE_SECRET_KEY ?? ''); + } + + async getStatus(userId: number) { + // Upsert: create row if first time + await this.db.query( + `INSERT INTO subscriptions (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING`, + [userId], + ); + + // Reset usage if past reset date + await this.db.query( + `UPDATE subscriptions + SET requests_used = 0, + usage_reset_at = date_trunc('month', NOW()) + INTERVAL '1 month' + WHERE user_id = $1 AND usage_reset_at < NOW()`, + [userId], + ); + + const result = await this.db.query( + `SELECT tier, requests_used, usage_reset_at, expires_at FROM subscriptions WHERE user_id = $1`, + [userId], + ); + const row = result.rows[0]; + + return { + tier: row.tier, + requestsUsed: row.requests_used, + requestsLimit: row.tier === 'ULTIMATE' ? null : 5, + resetAt: row.usage_reset_at, + expiresAt: row.expires_at ?? null, + }; + } + + async incrementUsage(userId: number) { + const sub = await this.db.query( + `SELECT tier, requests_used FROM subscriptions WHERE user_id = $1`, + [userId], + ); + const row = sub.rows[0]; + if (!row || row.tier === 'ULTIMATE') { + return { requestsUsed: row?.requests_used ?? 0, canMakeRequest: true }; + } + + const updated = await this.db.query( + `UPDATE subscriptions SET requests_used = requests_used + 1 + WHERE user_id = $1 RETURNING requests_used`, + [userId], + ); + const requestsUsed = updated.rows[0].requests_used; + return { requestsUsed, canMakeRequest: requestsUsed < 5 }; + } + + async cancelSubscription(userId: number) { + const result = await this.db.query( + `SELECT stripe_price_id, stripe_customer_id FROM subscriptions WHERE user_id = $1`, + [userId], + ); + const row = result.rows[0]; + + if (!row?.stripe_price_id) { + return { success: false, message: 'No active subscription found.' }; + } + + console.log('Cancelling subscription ID:', row.stripe_price_id); + + // Cancel at period end using stored subscription ID directly + await this.stripe.subscriptions.update(row.stripe_price_id, { + cancel_at_period_end: true, + }); + + const retrieved = await this.stripe.subscriptions.retrieve(row.stripe_price_id); + const rawEnd = (retrieved as any).cancel_at; + const periodEnd = rawEnd ? new Date(rawEnd * 1000) : null; + + await this.db.query( + `UPDATE subscriptions SET expires_at = $1 WHERE user_id = $2`, + [periodEnd, userId], + ); + + return { + success: true, + message: periodEnd + ? `Subscription cancelled. You have access until ${periodEnd.toLocaleDateString()}.` + : 'Subscription cancelled.', + }; + } + + async createCheckoutSession(userId: number, email: string) { + let sub = await this.db.query( + `SELECT stripe_customer_id FROM subscriptions WHERE user_id = $1`, + [userId], + ); + let customerId: string = sub.rows[0]?.stripe_customer_id; + + if (customerId) { + // Verify customer still exists and is not deleted in Stripe + try { + const existing = await this.stripe.customers.retrieve(customerId); + if ((existing as any).deleted) { + customerId = ''; + } + } catch { + customerId = ''; + } + if (!customerId) { + await this.db.query( + `UPDATE subscriptions SET stripe_customer_id = NULL, stripe_price_id = NULL, tier = 'COMMUNITY' WHERE user_id = $1`, + [userId], + ); + } + } + + if (!customerId) { + const customer = await this.stripe.customers.create({ email }); + customerId = customer.id; + await this.db.query( + `UPDATE subscriptions SET stripe_customer_id = $1 WHERE user_id = $2`, + [customerId, userId], + ); + } + + const session = await this.stripe.checkout.sessions.create({ + customer: customerId, + mode: 'subscription', + line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }], + client_reference_id: userId.toString(), + success_url: process.env.STRIPE_SUCCESS_URL ?? 'http://localhost:3000/admin?payment=success', + cancel_url: process.env.STRIPE_CANCEL_URL ?? 'http://localhost:3000/admin?payment=cancelled', + }); + + return { checkoutUrl: session.url }; + } +} diff --git a/Backend/tsconfig.json b/Backend/tsconfig.json index 95f5641..fe28bfe 100644 --- a/Backend/tsconfig.json +++ b/Backend/tsconfig.json @@ -6,6 +6,7 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, + "esModuleInterop": true, "target": "ES2021", "sourceMap": true, "outDir": "./dist", diff --git a/Frontend/.env b/Frontend/.env index 969ec20..77a7e4e 100644 --- a/Frontend/.env +++ b/Frontend/.env @@ -1 +1,3 @@ -API_URL=http://4.188.231.7/website +#API_URL=http://4.188.231.7/website +API_URL=http://localhost:4000 +NEXT_PUBLIC_API_URL=http://localhost:4000 \ No newline at end of file diff --git a/Frontend/app/pricing/page.tsx b/Frontend/app/pricing/page.tsx new file mode 100644 index 0000000..43f0287 --- /dev/null +++ b/Frontend/app/pricing/page.tsx @@ -0,0 +1,379 @@ +"use client"; + +import { useState, useEffect, useCallback, Suspense } from "react"; +import { useSearchParams } from "next/navigation"; +import { motion } from "framer-motion"; +import Link from "next/link"; +import AnimatedNav from "@/components/sections/AnimatedNav"; +import AnimatedFooter from "@/components/sections/AnimatedFooter"; + +const COMMUNITY_FEATURES = [ + "All 4 modules: CI/CD, Runtime Debugger, Code Analyzer, Code Generation", + "5 AI requests per month (shared across all modules)", + "Full IntelliJ IDEA integration", + "Hadolint & KubeLinter analysis", + "Community support", +]; + +const ULTIMATE_FEATURES = [ + "Everything in Community", + "Unlimited AI requests", + "Priority AI responses via Claude Sonnet on AWS Bedrock", + "Early access to new modules", + "Priority support", +]; + +const COMPARISON = [ + { feature: "All 4 plugin modules", community: true, ultimate: true }, + { feature: "AI requests / month", community: "5", ultimate: "Unlimited" }, + { feature: "CI/CD pipeline generation", community: true, ultimate: true }, + { feature: "Runtime analysis", community: true, ultimate: true }, + { feature: "Code quality scanning", community: true, ultimate: true }, + { feature: "Code generation", community: true, ultimate: true }, + { feature: "Hadolint & KubeLinter", community: true, ultimate: true }, + { feature: "Priority support", community: false, ultimate: true }, + { feature: "Early access to new features", community: false, ultimate: true }, +]; + +function Check() { + return ( + + + + ); +} + +function Cross() { + return ( + + + + ); +} + +const fadeUp = { + hidden: { opacity: 0, y: 20 }, + show: (i: number) => ({ + opacity: 1, + y: 0, + transition: { duration: 0.45, delay: i * 0.1, ease: "easeOut" }, + }), +}; + +function PricingContent() { + const searchParams = useSearchParams(); + const [token, setToken] = useState(null); + const [currentTier, setCurrentTier] = useState(null); + const [expiresAt, setExpiresAt] = useState(null); + const [loading, setLoading] = useState(false); + const [cancelling, setCancelling] = useState(false); + const [alert, setAlert] = useState<{ type: "success" | "error"; message: string } | null>(null); + + useEffect(() => { + const payment = searchParams.get("payment"); + if (payment === "success") { + setAlert({ type: "success", message: "You're now on Ultimate! Enjoy unlimited AI requests." }); + } else if (payment === "cancelled") { + setAlert({ type: "error", message: "Checkout cancelled. You can upgrade anytime." }); + } + + const t = localStorage.getItem("sf_token"); + setToken(t); + if (t) { + fetch("/api/subscription/status", { + headers: { Authorization: `Bearer ${t}` }, + }) + .then((r) => r.json()) + .then((data) => { + setCurrentTier(data.tier); + setExpiresAt(data.expiresAt ?? null); + }) + .catch(() => {}); + } + }, [searchParams]); + + const handleUpgrade = useCallback(async () => { + if (!token) { + window.location.href = "/login?redirect=/pricing"; + return; + } + setLoading(true); + setAlert(null); + try { + const res = await fetch("/api/subscription/create-checkout-session", { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json(); + if (res.ok && data.checkoutUrl) { + window.location.href = data.checkoutUrl; + } else { + setAlert({ type: "error", message: data.message ?? "Failed to start checkout." }); + } + } catch { + setAlert({ type: "error", message: "Network error. Please try again." }); + } finally { + setLoading(false); + } + }, [token]); + + const handleCancel = useCallback(async () => { + if (!token) return; + if (!confirm("Cancel your Ultimate subscription? You'll keep access until the end of your billing period.")) return; + setCancelling(true); + setAlert(null); + try { + const res = await fetch("/api/subscription/cancel", { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json(); + if (res.ok && data.success) { + setAlert({ type: "success", message: data.message }); + // Refresh status to pick up expiresAt + const statusRes = await fetch("/api/subscription/status", { + headers: { Authorization: `Bearer ${token}` }, + }); + const statusData = await statusRes.json(); + setCurrentTier(statusData.tier); + setExpiresAt(statusData.expiresAt ?? null); + } else { + setAlert({ type: "error", message: data.message ?? "Failed to cancel subscription." }); + } + } catch { + setAlert({ type: "error", message: "Network error. Please try again." }); + } finally { + setCancelling(false); + } + }, [token]); + + return ( +
+ + +
+
+ + {/* Header */} + + + Pricing + +

+ Simple, transparent pricing +

+

+ Start free. Upgrade when you need unlimited AI power. +

+
+ + {alert && ( +
+ {alert.message} +
+ )} + + {/* Pricing Cards */} +
+ + {/* Community */} + +
+

Community

+
+ $0 + / month +
+

Free forever. No credit card required.

+ +
    + {COMMUNITY_FEATURES.map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ +
+ {currentTier === "COMMUNITY" ? ( +
+ ✓ Current plan +
+ ) : ( + + Get started free + + )} +
+
+ + {/* Ultimate */} + + {/* Recommended badge */} +
+ + Recommended + +
+ +
+

Ultimate

+
+ $9 + / month +
+

Unlimited AI. No throttling. Ever.

+ +
    + {ULTIMATE_FEATURES.map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ +
+ {currentTier === "ULTIMATE" ? ( + <> +
+ ✦ Current plan +
+ {expiresAt ? ( +
+ Cancels on {new Date(expiresAt).toLocaleDateString()} +
+ ) : ( + + )} + + ) : ( + + )} +
+
+
+ + {/* Comparison Table */} + +

Full comparison

+
+ {/* Table header */} +
+ Feature + Community + Ultimate +
+ {/* Rows */} + {COMPARISON.map((row, i) => ( +
+ {row.feature} + + {typeof row.community === "boolean" + ? row.community ? : + : {row.community} + } + + + {typeof row.ultimate === "boolean" + ? row.ultimate ? : + : {row.ultimate} + } + +
+ ))} +
+
+ + {/* Bottom CTA */} + +

Ready to go unlimited?

+

+ Cancel anytime. Billed monthly via Stripe. +

+
+ + + Download free plugin + +
+
+ +
+
+ + +
+ ); +} + +export default function PricingPage() { + return ( + + + + ); +} diff --git a/Frontend/components/sections/AnimatedNav.tsx b/Frontend/components/sections/AnimatedNav.tsx index d61ce80..1911ccb 100644 --- a/Frontend/components/sections/AnimatedNav.tsx +++ b/Frontend/components/sections/AnimatedNav.tsx @@ -129,6 +129,12 @@ export default function AnimatedNav() { > About + + Pricing + {user ? ( <>