project init
This commit is contained in:
41
apps/api/src/payments/payments.controller.ts
Normal file
41
apps/api/src/payments/payments.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/payments/payments.module.ts
Normal file
12
apps/api/src/payments/payments.module.ts
Normal 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 {}
|
||||
231
apps/api/src/payments/payments.service.ts
Normal file
231
apps/api/src/payments/payments.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
69
apps/api/src/payments/stripe.service.ts
Normal file
69
apps/api/src/payments/stripe.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
52
apps/api/src/payments/webhooks.controller.ts
Normal file
52
apps/api/src/payments/webhooks.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user