Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 21 additions & 3 deletions Backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions Backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -14,6 +16,8 @@ import { HealthModule } from './health/health.module';
AdminModule,
FeedbackModule,
HealthModule,
SubscriptionModule,
StripeModule,
],
})
export class AppModule {}
14 changes: 14 additions & 0 deletions Backend/src/database/database.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
2 changes: 1 addition & 1 deletion Backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
57 changes: 57 additions & 0 deletions Backend/src/stripe/stripe-webhook.controller.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Stripe>;

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 });
}
}
7 changes: 7 additions & 0 deletions Backend/src/stripe/stripe.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { StripeWebhookController } from './stripe-webhook.controller';

@Module({
controllers: [StripeWebhookController],
})
export class StripeModule {}
31 changes: 31 additions & 0 deletions Backend/src/subscription/subscription.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
11 changes: 11 additions & 0 deletions Backend/src/subscription/subscription.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
143 changes: 143 additions & 0 deletions Backend/src/subscription/subscription.service.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Stripe>;

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 };
}
}
1 change: 1 addition & 0 deletions Backend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
Expand Down
4 changes: 3 additions & 1 deletion Frontend/.env
Original file line number Diff line number Diff line change
@@ -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
Loading