project init

This commit is contained in:
2026-02-06 02:17:59 +03:00
commit b9d9b9ed17
129 changed files with 22835 additions and 0 deletions

View File

@ -0,0 +1,41 @@
import {
Controller,
Get,
Post,
Body,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { PaymentsService } from './payments.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Public } from '../auth/decorators/public.decorator';
import { User } from '@coursecraft/database';
@ApiTags('subscriptions')
@Controller('subscriptions')
export class PaymentsController {
constructor(private paymentsService: PaymentsService) {}
@Get('plans')
@Public()
@ApiOperation({ summary: 'Get available subscription plans' })
async getPlans() {
return this.paymentsService.getPlans();
}
@Post('checkout')
@ApiBearerAuth()
@ApiOperation({ summary: 'Create Stripe checkout session' })
async createCheckoutSession(
@CurrentUser() user: User,
@Body('tier') tier: 'PREMIUM' | 'PRO'
) {
return this.paymentsService.createCheckoutSession(user.id, tier);
}
@Post('portal')
@ApiBearerAuth()
@ApiOperation({ summary: 'Create Stripe customer portal session' })
async createPortalSession(@CurrentUser() user: User) {
return this.paymentsService.createPortalSession(user.id);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
import { StripeService } from './stripe.service';
import { WebhooksController } from './webhooks.controller';
@Module({
controllers: [PaymentsController, WebhooksController],
providers: [PaymentsService, StripeService],
exports: [PaymentsService, StripeService],
})
export class PaymentsModule {}

View File

@ -0,0 +1,231 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../common/prisma/prisma.service';
import { StripeService } from './stripe.service';
import { SubscriptionTier } from '@coursecraft/database';
import { SUBSCRIPTION_PLANS } from '@coursecraft/shared';
@Injectable()
export class PaymentsService {
constructor(
private prisma: PrismaService,
private stripeService: StripeService,
private configService: ConfigService
) {}
async getPlans() {
return SUBSCRIPTION_PLANS.map((plan) => ({
tier: plan.tier,
name: plan.name,
nameRu: plan.nameRu,
description: plan.description,
descriptionRu: plan.descriptionRu,
price: plan.price,
currency: plan.currency,
features: plan.features,
featuresRu: plan.featuresRu,
}));
}
async createCheckoutSession(userId: string, tier: 'PREMIUM' | 'PRO') {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: { subscription: true },
});
if (!user) {
throw new NotFoundException('User not found');
}
// Get or create Stripe customer
let stripeCustomerId = user.subscription?.stripeCustomerId;
if (!stripeCustomerId) {
const customer = await this.stripeService.createCustomer(user.email, user.name || undefined);
stripeCustomerId = customer.id;
await this.prisma.subscription.upsert({
where: { userId },
create: {
userId,
tier: SubscriptionTier.FREE,
stripeCustomerId: customer.id,
},
update: {
stripeCustomerId: customer.id,
},
});
}
// Get price ID for tier
const priceId =
tier === 'PREMIUM'
? this.configService.get<string>('STRIPE_PRICE_PREMIUM')
: this.configService.get<string>('STRIPE_PRICE_PRO');
if (!priceId) {
throw new Error(`Price ID not configured for tier: ${tier}`);
}
const appUrl = this.configService.get<string>('NEXT_PUBLIC_APP_URL') || 'http://localhost:3000';
const session = await this.stripeService.createCheckoutSession({
customerId: stripeCustomerId,
priceId,
successUrl: `${appUrl}/dashboard/billing?success=true`,
cancelUrl: `${appUrl}/dashboard/billing?canceled=true`,
metadata: {
userId,
tier,
},
});
return { url: session.url };
}
async createPortalSession(userId: string) {
const subscription = await this.prisma.subscription.findUnique({
where: { userId },
});
if (!subscription?.stripeCustomerId) {
throw new NotFoundException('No subscription found');
}
const appUrl = this.configService.get<string>('NEXT_PUBLIC_APP_URL') || 'http://localhost:3000';
const session = await this.stripeService.createPortalSession(
subscription.stripeCustomerId,
`${appUrl}/dashboard/billing`
);
return { url: session.url };
}
async handleWebhookEvent(event: { type: string; data: { object: unknown } }) {
switch (event.type) {
case 'checkout.session.completed':
await this.handleCheckoutCompleted(event.data.object as {
customer: string;
subscription: string;
metadata: { userId: string; tier: string };
});
break;
case 'customer.subscription.updated':
await this.handleSubscriptionUpdated(event.data.object as {
id: string;
customer: string;
status: string;
current_period_end: number;
cancel_at_period_end: boolean;
items: { data: Array<{ price: { id: string } }> };
});
break;
case 'customer.subscription.deleted':
await this.handleSubscriptionDeleted(event.data.object as {
customer: string;
});
break;
}
}
private async handleCheckoutCompleted(session: {
customer: string;
subscription: string;
metadata: { userId: string; tier: string };
}) {
const { customer, subscription: subscriptionId, metadata } = session;
const tier = metadata.tier as SubscriptionTier;
const stripeSubscription = await this.stripeService.getSubscription(subscriptionId);
await this.prisma.subscription.update({
where: { stripeCustomerId: customer },
data: {
tier,
stripeSubscriptionId: subscriptionId,
stripePriceId: stripeSubscription.items.data[0]?.price.id,
status: 'active',
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
coursesCreatedThisMonth: 0, // Reset on new subscription
},
});
// Update user's subscription tier
await this.prisma.user.update({
where: { id: metadata.userId },
data: { subscriptionTier: tier },
});
}
private async handleSubscriptionUpdated(subscription: {
id: string;
customer: string;
status: string;
current_period_end: number;
cancel_at_period_end: boolean;
items: { data: Array<{ price: { id: string } }> };
}) {
const priceId = subscription.items.data[0]?.price.id;
// Determine tier from price ID
const premiumPriceId = this.configService.get<string>('STRIPE_PRICE_PREMIUM');
const proPriceId = this.configService.get<string>('STRIPE_PRICE_PRO');
let tier: SubscriptionTier = SubscriptionTier.FREE;
if (priceId === premiumPriceId) tier = SubscriptionTier.PREMIUM;
else if (priceId === proPriceId) tier = SubscriptionTier.PRO;
await this.prisma.subscription.update({
where: { stripeCustomerId: subscription.customer as string },
data: {
tier,
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
stripePriceId: priceId,
},
});
// Update user's tier
const sub = await this.prisma.subscription.findUnique({
where: { stripeCustomerId: subscription.customer as string },
});
if (sub) {
await this.prisma.user.update({
where: { id: sub.userId },
data: { subscriptionTier: tier },
});
}
}
private async handleSubscriptionDeleted(subscription: { customer: string }) {
await this.prisma.subscription.update({
where: { stripeCustomerId: subscription.customer },
data: {
tier: SubscriptionTier.FREE,
status: 'canceled',
stripeSubscriptionId: null,
stripePriceId: null,
currentPeriodEnd: null,
cancelAtPeriodEnd: false,
},
});
// Update user's tier
const sub = await this.prisma.subscription.findUnique({
where: { stripeCustomerId: subscription.customer },
});
if (sub) {
await this.prisma.user.update({
where: { id: sub.userId },
data: { subscriptionTier: SubscriptionTier.FREE },
});
}
}
}

View File

@ -0,0 +1,69 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Stripe from 'stripe';
@Injectable()
export class StripeService {
private stripe: Stripe;
constructor(private configService: ConfigService) {
this.stripe = new Stripe(this.configService.get<string>('STRIPE_SECRET_KEY')!, {
apiVersion: '2023-10-16',
});
}
getClient(): Stripe {
return this.stripe;
}
async createCustomer(email: string, name?: string): Promise<Stripe.Customer> {
return this.stripe.customers.create({
email,
name: name || undefined,
});
}
async createCheckoutSession(params: {
customerId: string;
priceId: string;
successUrl: string;
cancelUrl: string;
metadata?: Record<string, string>;
}): Promise<Stripe.Checkout.Session> {
return this.stripe.checkout.sessions.create({
customer: params.customerId,
mode: 'subscription',
line_items: [
{
price: params.priceId,
quantity: 1,
},
],
success_url: params.successUrl,
cancel_url: params.cancelUrl,
metadata: params.metadata,
});
}
async createPortalSession(customerId: string, returnUrl: string): Promise<Stripe.BillingPortal.Session> {
return this.stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
});
}
async getSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
return this.stripe.subscriptions.retrieve(subscriptionId);
}
async cancelSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
return this.stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
}
constructWebhookEvent(payload: Buffer, signature: string): Stripe.Event {
const webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET')!;
return this.stripe.webhooks.constructEvent(payload, signature, webhookSecret);
}
}

View File

@ -0,0 +1,52 @@
import {
Controller,
Post,
Headers,
Req,
HttpCode,
HttpStatus,
BadRequestException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiExcludeEndpoint } from '@nestjs/swagger';
import { Request } from 'express';
import { PaymentsService } from './payments.service';
import { StripeService } from './stripe.service';
@ApiTags('webhooks')
@Controller('webhooks')
export class WebhooksController {
constructor(
private paymentsService: PaymentsService,
private stripeService: StripeService
) {}
@Post('stripe')
@HttpCode(HttpStatus.OK)
@ApiExcludeEndpoint()
@ApiOperation({ summary: 'Handle Stripe webhooks' })
async handleStripeWebhook(
@Req() req: Request,
@Headers('stripe-signature') signature: string
) {
if (!signature) {
throw new BadRequestException('Missing stripe-signature header');
}
let event;
try {
// req.body should be raw buffer for webhook verification
const rawBody = (req as Request & { rawBody?: Buffer }).rawBody;
if (!rawBody) {
throw new BadRequestException('Missing raw body');
}
event = this.stripeService.constructWebhookEvent(rawBody, signature);
} catch (err) {
throw new BadRequestException(`Webhook signature verification failed: ${err}`);
}
await this.paymentsService.handleWebhookEvent(event);
return { received: true };
}
}