add sponsorship, stripe

This commit is contained in:
Sergey Krylov 2025-07-28 05:15:00 +03:00
parent 5b6d8cccb7
commit bfe3d9573c
34 changed files with 2459 additions and 55 deletions

View File

@ -38,3 +38,76 @@ export default tseslint.config(
}, },
}, },
); );
// import config from 'eslint-config-ksv741/ts-base'
//
// export default [
// ...config,
// {
// name: 'my-custom',
// files: [
// '**/*.js',
// '**/*.ts'
// ],
// rules: {
// 'func-style': ["error", "declaration", { "allowArrowFunctions": true }],
// 'import/order': [
// 'error', {
// pathGroups: [
// {
// pattern: '__spec__/**',
// group: 'builtin',
// position: 'before',
// },
// {
// pattern: 'apps/**',
// group: 'internal',
// position: 'after',
// },
// {
// pattern: 'pages/**',
// group: 'internal',
// position: 'after',
// },
// {
// pattern: 'widgets/**',
// group: 'internal',
// position: 'after',
// },
// {
// pattern: 'features/**',
// group: 'internal',
// position: 'after',
// },
// {
// pattern: 'entities/**',
// group: 'internal',
// position: 'after',
// },
// {
// pattern: 'shared/**',
// group: 'internal',
// position: 'after',
// },
// ],
// distinctGroup: true,
// groups: [
// 'builtin',
// 'external',
// 'internal',
// 'parent',
// 'sibling',
// 'object',
// 'index',
// 'type',
// ],
// 'newlines-between': 'always',
// alphabetize: {
// order: 'asc',
// caseInsensitive: true,
// },
// },
// ],
// }
// }
// ];

View File

@ -11,7 +11,8 @@
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"lint:inspect": "npx @eslint/config-inspector@latest",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
@ -54,6 +55,7 @@
"i18n-iso-countries": "^7.14.0", "i18n-iso-countries": "^7.14.0",
"ioredis": "^5.6.1", "ioredis": "^5.6.1",
"livekit-server-sdk": "1.2.7", "livekit-server-sdk": "1.2.7",
"nestjs-telegraf": "^2.9.1",
"otpauth": "^9.4.0", "otpauth": "^9.4.0",
"prisma": "^6.10.1", "prisma": "^6.10.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@ -62,7 +64,9 @@
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sharp": "^0.34.2" "sharp": "^0.34.2",
"stripe": "^18.3.0",
"telegraf": "^4.16.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0", "@eslint/eslintrc": "^3.2.0",
@ -83,6 +87,7 @@
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-ksv741": "0.2.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",

View File

@ -94,6 +94,10 @@ model User {
notifications Notification[] notifications Notification[]
notificationSettings NotificationSettings? notificationSettings NotificationSettings?
telegramId String? @unique @map("telegram_id") telegramId String? @unique @map("telegram_id")
transactions Transaction[]
sponsorshipPlans SponsorshipPlan[]
sponsorshipSubscriptions SponsorshipSubscription[] @relation(name: "sponsorship_subscriptions")
sponsors SponsorshipSubscription[] @relation(name: "sponsors")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@ -126,6 +130,51 @@ model Token {
@@map("tokens") @@map("tokens")
} }
model Transaction {
id String @id @default(uuid())
amount Float
currency String
stripeSubscriptionId String? @map("stripe_subscription_id")
status TransactionStatus @default(PENDING)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("transactions")
}
model SponsorshipPlan {
id String @id @default(uuid())
title String
description String?
price Float
stripeProductId String @map("stripe_product_id")
stripePlanId String @map("stripe_plan_id")
channel User? @relation(fields: [channelId], references: [id], onDelete: Cascade)
channelId String? @map("channel_id")
sponsorshipSubscriptions SponsorshipSubscription[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("sponsorship_plans")
}
model SponsorshipSubscription {
id String @id @default(uuid())
expiresAt DateTime @map("expires_at")
plan SponsorshipPlan? @relation(fields: [planId], references: [id], onDelete: Cascade)
planId String? @map("plan_id")
user User? @relation(name: "sponsorship_subscriptions", fields: [userId], references: [id], onDelete: Cascade)
userId String? @map("user_id")
channel User? @relation(name: "sponsors", fields: [channelId], references: [id], onDelete: Cascade)
channelId String? @map("channel_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("sponsorship_subscriptions")
}
model Notification { model Notification {
id String @id @default(uuid()) id String @id @default(uuid())
text String text String
@ -169,3 +218,12 @@ enum TokenType {
@@map("token_types") @@map("token_types")
} }
enum TransactionStatus {
PENDING
SUCCESS
FAILED
EXPIRED
@@map("transaction_statuses")
}

View File

@ -0,0 +1,12 @@
import { TypeStripeOptions } from '@/src/module/libs/stripe/types/stripe.type'
import { ProcessEnv } from '@/src/shared/types/env'
import { ConfigService } from '@nestjs/config'
export function getStripeConfig(configService: ConfigService<ProcessEnv>): TypeStripeOptions {
return {
config: {
apiVersion: '2025-06-30.basil',
},
apiKey: configService.getOrThrow('STRIPE_SECRET_KEY'),
}
}

View File

@ -1,5 +1,6 @@
import { getGraphQLConfig } from '@/src/core/config/graphql.config' import { getGraphQLConfig } from '@/src/core/config/graphql.config'
import { getLiveKitConfig } from '@/src/core/config/livekit.config' import { getLiveKitConfig } from '@/src/core/config/livekit.config'
import { getStripeConfig } from '@/src/core/config/stripe.config';
import { AccountModule } from '@/src/module/auth/account/account.module' import { AccountModule } from '@/src/module/auth/account/account.module'
import { DeactivateModule } from '@/src/module/auth/deactivate/deactivate.module' import { DeactivateModule } from '@/src/module/auth/deactivate/deactivate.module'
import { PasswordRecoveryModule } from '@/src/module/auth/password-recovery/password-recovery.module' import { PasswordRecoveryModule } from '@/src/module/auth/password-recovery/password-recovery.module'
@ -15,8 +16,12 @@ import { FollowModule } from '@/src/module/follow/follow.module'
import { LiveKitModule } from '@/src/module/libs/livekit/livekit.module' import { LiveKitModule } from '@/src/module/libs/livekit/livekit.module'
import { MailModule } from '@/src/module/libs/mail/mail.module' import { MailModule } from '@/src/module/libs/mail/mail.module'
import { StorageModule } from '@/src/module/libs/storage/storage.module' import { StorageModule } from '@/src/module/libs/storage/storage.module'
import { StripeModule } from '@/src/module/libs/stripe/stripe.module';
import { TelegramModule } from '@/src/module/libs/telegram/telegram.module' import { TelegramModule } from '@/src/module/libs/telegram/telegram.module'
import { NotificationModule } from '@/src/module/notification/notification.module' import { NotificationModule } from '@/src/module/notification/notification.module'
import { PlanModule } from '@/src/module/sponsorship/plan/plan.module';
import { SubscriptionModule } from '@/src/module/sponsorship/subscription/subscription.module';
import { TransactionModule } from '@/src/module/sponsorship/transaction/transaction.module';
import { IngressModule } from '@/src/module/stream/ingress/ingress.module' import { IngressModule } from '@/src/module/stream/ingress/ingress.module'
import { StreamModule } from '@/src/module/stream/stream.module' import { StreamModule } from '@/src/module/stream/stream.module'
import { WebhookModule } from '@/src/module/webhook/webhook.module' import { WebhookModule } from '@/src/module/webhook/webhook.module'
@ -51,6 +56,11 @@ import { RedisModule } from './redis/redis.module'
inject: [ConfigService], inject: [ConfigService],
}), }),
TelegramModule, TelegramModule,
StripeModule.registerAsync({
imports: [ConfigModule],
useFactory: getStripeConfig,
inject: [ConfigService],
}),
AccountModule, AccountModule,
SessionModule, SessionModule,
VerificationModule, VerificationModule,
@ -66,6 +76,9 @@ import { RedisModule } from './redis/redis.module'
FollowModule, FollowModule,
ChannelModule, ChannelModule,
NotificationModule, NotificationModule,
PlanModule,
TransactionModule,
SubscriptionModule,
], ],
}) })
export class CoreModule {} export class CoreModule {}

View File

@ -63,6 +63,12 @@ type ChatMessageModel {
userId: ID! userId: ID!
} }
input CreatePlanInput {
description: String
price: Float!
title: String!
}
input CreateUserInput { input CreateUserInput {
email: String! email: String!
name: String! name: String!
@ -129,6 +135,10 @@ input LoginInput {
pin: String pin: String
} }
type MakePaymentModel {
url: String!
}
type Mutation { type Mutation {
changeChatSettings(data: ChangeChatSettingsInput!): StreamModel! changeChatSettings(data: ChangeChatSettingsInput!): StreamModel!
changeEmail(data: ChangeEmailInput!): UserModel! changeEmail(data: ChangeEmailInput!): UserModel!
@ -141,6 +151,7 @@ type Mutation {
clearSessionCookie: Boolean! clearSessionCookie: Boolean!
createIngress(ingressType: Float!): Boolean! createIngress(ingressType: Float!): Boolean!
createSocialLink(data: SocialLinkInput!): SocialLinkModel! createSocialLink(data: SocialLinkInput!): SocialLinkModel!
createSponsorshipPlan(data: CreatePlanInput!): PlanModel!
createUser(data: CreateUserInput!): UserModel! createUser(data: CreateUserInput!): UserModel!
deactivateAccount(data: DeactivateAccountInput!): AuthModel! deactivateAccount(data: DeactivateAccountInput!): AuthModel!
disableTotp: Boolean! disableTotp: Boolean!
@ -149,9 +160,11 @@ type Mutation {
generateStreamToken(data: GenerateStreamTokenInput!): GenerateTokenModel! generateStreamToken(data: GenerateStreamTokenInput!): GenerateTokenModel!
loginUser(data: LoginInput!): AuthModel! loginUser(data: LoginInput!): AuthModel!
logoutUser: Boolean! logoutUser: Boolean!
makePayment(planId: String!): MakePaymentModel!
removeProfileAvatar: Boolean! removeProfileAvatar: Boolean!
removeSession(id: String!): Boolean! removeSession(id: String!): Boolean!
removeSocialLink(id: String!): Boolean! removeSocialLink(id: String!): Boolean!
removeSponsorshipPlan(planId: String!): PlanModel!
removeStreamThumbnail: Boolean! removeStreamThumbnail: Boolean!
reorderSocialLink(list: [SocialLinkOrderInput!]!): Boolean! reorderSocialLink(list: [SocialLinkOrderInput!]!): Boolean!
resetPassword(data: ResetPasswordInput!): Boolean! resetPassword(data: ResetPasswordInput!): Boolean!
@ -197,6 +210,19 @@ enum NotificationType {
VERIFIED_CHANNEL VERIFIED_CHANNEL
} }
type PlanModel {
channel: UserModel!
channelId: ID!
createdAt: DateTime!
description: String
id: ID!
price: Float!
stripePlanId: ID!
stripeProductId: ID!
title: String!
updatedAt: DateTime!
}
type Query { type Query {
findAllCategories: [CategoryModel!]! findAllCategories: [CategoryModel!]!
findAllStreams(filters: FilterInput!): [StreamModel!]! findAllStreams(filters: FilterInput!): [StreamModel!]!
@ -207,6 +233,9 @@ type Query {
findMessagesByStream(streamId: String!): [ChatMessageModel!]! findMessagesByStream(streamId: String!): [ChatMessageModel!]!
findMyFollowers: [FollowModel!]! findMyFollowers: [FollowModel!]!
findMyFollowings: [FollowModel!]! findMyFollowings: [FollowModel!]!
findMySponsors: [SubscriptionModel!]!
findMySponsorshipPlans: [PlanModel!]!
findMyTransactions: [TransactionModel!]!
findNotificationByUser: [NotificationModel!]! findNotificationByUser: [NotificationModel!]!
findProfile: UserModel! findProfile: UserModel!
findRandomCategories: [CategoryModel!]! findRandomCategories: [CategoryModel!]!
@ -214,6 +243,7 @@ type Query {
findRecommendedChannels: [UserModel!]! findRecommendedChannels: [UserModel!]!
findSessionsByUser: [SessionModel!]! findSessionsByUser: [SessionModel!]!
findSocialLinks: [SocialLinkModel!]! findSocialLinks: [SocialLinkModel!]!
findSponsorsByChannel(channelId: String!): [SubscriptionModel!]!
findUnreadNotificationsCount: Float! findUnreadNotificationsCount: Float!
generateTotpSecret: TotpModel! generateTotpSecret: TotpModel!
} }
@ -284,11 +314,43 @@ type Subscription {
chatMessageAdded(streamId: String!): ChatMessageModel! chatMessageAdded(streamId: String!): ChatMessageModel!
} }
type SubscriptionModel {
channel: UserModel!
channelId: String!
createdAt: DateTime!
expiresAt: DateTime!
id: ID!
plan: PlanModel!
planId: String!
updatedAt: DateTime!
user: UserModel!
userId: String!
}
type TotpModel { type TotpModel {
qrcodeUrl: String! qrcodeUrl: String!
secret: String! secret: String!
} }
type TransactionModel {
amount: Float!
createdAt: DateTime!
currency: String!
id: ID!
status: TransactionStatus!
stripeSubscriptionId: ID!
updatedAt: DateTime!
user: UserModel!
userId: ID!
}
enum TransactionStatus {
EXPIRED
FAILED
PENDING
SUCCESS
}
"""The `Upload` scalar type represents a file upload.""" """The `Upload` scalar type represents a file upload."""
scalar Upload scalar Upload

View File

@ -11,7 +11,7 @@ import * as session from 'express-session'
import * as graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.js' import * as graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.js'
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(CoreModule) const app = await NestFactory.create(CoreModule, { rawBody: true })
const config = app.get(ConfigService) const config = app.get(ConfigService)
const redis = app.get(RedisService) const redis = app.get(RedisService)

View File

@ -1,4 +1,5 @@
import { UserModel } from '@/src/module/auth/account/models/user.model' import { UserModel } from '@/src/module/auth/account/models/user.model'
import { SubscriptionModel } from '@/src/module/sponsorship/subscription/model/subscription.model'
import { Args, Query, Resolver } from '@nestjs/graphql' import { Args, Query, Resolver } from '@nestjs/graphql'
import { ChannelService } from './channel.service' import { ChannelService } from './channel.service'
@ -20,4 +21,9 @@ export class ChannelResolver {
public async findFollowersCount(@Args('channelId') channelId: string) { public async findFollowersCount(@Args('channelId') channelId: string) {
return this.channelService.findFollowersCountByChannel(channelId) return this.channelService.findFollowersCountByChannel(channelId)
} }
@Query(() => [SubscriptionModel], { name: 'findSponsorsByChannel' })
public async findSponsorsByChannel(@Args('channelId') channelId: string) {
return this.channelService.findSponsorsByChannel(channelId)
}
} }

View File

@ -39,4 +39,22 @@ export class ChannelService {
where: { following: { id: channelId } }, where: { following: { id: channelId } },
}) })
} }
public async findSponsorsByChannel(channelId: string) {
const channel = await this.prismaService.user.findUnique({
where: { id: channelId, isDeactivated: false },
})
if (!channel) {
throw new NotFoundException('Канал не найден')
}
return this.prismaService.sponsorshipSubscription.findMany({
where: { channelId: channel.id },
orderBy: { createdAt: 'desc' },
include: {
user: true,
},
})
}
} }

View File

@ -3,7 +3,7 @@ import { DeactivateTemplate } from '@/src/module/libs/mail/templates/deactivate.
import PasswordRecoveryTemplate from '@/src/module/libs/mail/templates/password-recovery.template' import PasswordRecoveryTemplate from '@/src/module/libs/mail/templates/password-recovery.template'
import { SessionInfo } from '@/src/shared/types/session-metadata.types' import { SessionInfo } from '@/src/shared/types/session-metadata.types'
import { Token } from '@prisma/generated' import { Token } from '@prisma/generated'
import { ProcessEnv } from '../../../shared/types/env'; import { ProcessEnv } from '@/src/shared/types/env'
import VerificationTemplate from './templates/verification.template' import VerificationTemplate from './templates/verification.template'
import { MailerService } from '@nestjs-modules/mailer' import { MailerService } from '@nestjs-modules/mailer'
import { Injectable } from '@nestjs/common' import { Injectable } from '@nestjs/common'

View File

@ -0,0 +1,38 @@
import { DynamicModule, Module } from '@nestjs/common'
import { StripeOptionSymbol, TypeStripeAsyncOptions, TypeStripeOptions } from './types/stripe.type'
import { StripeService } from './stripe.service'
@Module({})
export class StripeModule {
public static register(options?: TypeStripeOptions): DynamicModule {
return {
module: StripeModule,
providers: [
{
provide: StripeOptionSymbol,
useValue: options,
},
StripeService,
],
exports: [StripeService],
global: true,
}
}
public static registerAsync(options: TypeStripeAsyncOptions): DynamicModule {
return {
module: StripeModule,
imports: options.imports || [],
providers: [
{
provide: StripeOptionSymbol,
useFactory: options.useFactory,
inject: options.inject || [],
},
StripeService,
],
exports: [StripeService],
global: true,
}
}
}

View File

@ -0,0 +1,13 @@
import { StripeOptionSymbol, TypeStripeOptions } from '@/src/module/libs/stripe/types/stripe.type'
import { Inject, Injectable } from '@nestjs/common'
import Stripe from 'stripe'
@Injectable()
export class StripeService extends Stripe {
constructor(
@Inject(StripeOptionSymbol)
private readonly options: TypeStripeOptions,
) {
super(options.apiKey, options.config)
}
}

View File

@ -0,0 +1,12 @@
import { FactoryProvider, ModuleMetadata } from '@nestjs/common'
import Stripe from 'stripe'
export const StripeOptionSymbol = Symbol('StripeOptionSymbol')
export type TypeStripeOptions = {
apiKey: string
config?: Stripe.StripeConfig
}
export type TypeStripeAsyncOptions = Pick<ModuleMetadata, 'imports'>
& Pick<FactoryProvider<TypeStripeOptions>, 'useFactory' | 'inject'>

View File

@ -1,5 +1,5 @@
import { SessionInfo } from '@/src/shared/types/session-metadata.types' import { SessionInfo } from '@/src/shared/types/session-metadata.types'
import type { User } from '@prisma/generated' import type { SponsorshipPlan, User } from '@prisma/generated'
export const MESSAGES = { export const MESSAGES = {
welcome: welcome:
@ -71,4 +71,10 @@ export const MESSAGES = {
+ `Мы рады сообщить, что ваш канал теперь верифицирован, и вы получили официальный значок.\n\n` + `Мы рады сообщить, что ваш канал теперь верифицирован, и вы получили официальный значок.\n\n`
+ `Значок верификации подтверждает подлинность вашего канала и улучшает доверие зрителей.\n\n` + `Значок верификации подтверждает подлинность вашего канала и улучшает доверие зрителей.\n\n`
+ `Спасибо, что вы с нами и продолжаете развивать свой канал вместе с TeaStream!`, + `Спасибо, что вы с нами и продолжаете развивать свой канал вместе с TeaStream!`,
newSponsorship: (plan: SponsorshipPlan, sponsor: User) =>
`<b>🎉 Новое спонсор!</b>\n\n`
+ `Вы получили новое спонсорство на план <b>${plan.title}</b>.\n`
+ `💰 Сумма: <b>${plan.price} ₽</b>\n`
+ `👤 Спонсор: <a href="https://teastream.ru/${sponsor.name}">${sponsor.displayName}</a>\n`
+ `📅 Дата оформления: <b>${new Date().toLocaleDateString()} в ${new Date().toLocaleTimeString()}</b>`,
} }

View File

@ -10,7 +10,7 @@ import { Context, Telegraf } from 'telegraf'
import { PrismaService } from '@/src/core/prisma/prisma.service' import { PrismaService } from '@/src/core/prisma/prisma.service'
import { BUTTONS } from '@/src/module/libs/telegram/telegram.button' import { BUTTONS } from '@/src/module/libs/telegram/telegram.button'
import { MESSAGES } from '@/src/module/libs/telegram/telegram.message' import { MESSAGES } from '@/src/module/libs/telegram/telegram.message'
import { TokenType, User } from '@prisma/generated' import { SponsorshipPlan, TokenType, User } from '@prisma/generated'
import { ProcessEnv } from '../../../shared/types/env' import { ProcessEnv } from '../../../shared/types/env'
@ -132,6 +132,10 @@ export class TelegramService extends Telegraf {
await this.telegram.sendMessage(chatId, MESSAGES.streamStart(channel), { parse_mode: 'HTML' }) await this.telegram.sendMessage(chatId, MESSAGES.streamStart(channel), { parse_mode: 'HTML' })
} }
public async sendNewSponsorship(chatId: string, plan: SponsorshipPlan, sponsor: User) {
await this.telegram.sendMessage(chatId, MESSAGES.newSponsorship(plan, sponsor), { parse_mode: 'HTML' })
}
public async sendNewFollowing(chatId: string, follower: User) { public async sendNewFollowing(chatId: string, follower: User) {
const user = await this.findUserByChatId(chatId) const user = await this.findUserByChatId(chatId)
if (!user) { if (!user) {

View File

@ -2,7 +2,7 @@ import { PrismaService } from '@/src/core/prisma/prisma.service'
import { ChangeNotificationSettingsInput } from '@/src/module/notification/inputs/change-notification-settings.input' import { ChangeNotificationSettingsInput } from '@/src/module/notification/inputs/change-notification-settings.input'
import { generateToken } from '@/src/shared/util/generate-token.util' import { generateToken } from '@/src/shared/util/generate-token.util'
import { Injectable } from '@nestjs/common' import { Injectable } from '@nestjs/common'
import { $Enums, User } from '@prisma/generated' import { $Enums, SponsorshipPlan, User } from '@prisma/generated'
import TokenType = $Enums.TokenType import TokenType = $Enums.TokenType
import NotificationType = $Enums.NotificationType import NotificationType = $Enums.NotificationType
@ -107,6 +107,21 @@ export class NotificationService {
}) })
} }
public async createNewSponsorship(userId: string, plan: SponsorshipPlan, sponsor: User) {
return this.prismaService.notification.create({
data: {
text: `<b className='font-medium'>У вас новый спонсор!</b>
<p>Пользователь <a href='/${sponsor.name}' className='font-semibold'>${sponsor.displayName}</a> стал вашим спонсором, выбрав план <strong>${plan.title}</strong>.</p>`,
type: NotificationType.NEW_SPONSORSHIP,
user: {
connect: {
id: userId,
},
},
},
})
}
public async createEnableTwoFactor(userId: string) { public async createEnableTwoFactor(userId: string) {
return this.prismaService.notification.create({ return this.prismaService.notification.create({
data: { data: {

View File

@ -0,0 +1,20 @@
import { Field, InputType } from '@nestjs/graphql'
import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'
@InputType()
export class CreatePlanInput {
@Field(() => String)
@IsString()
@IsNotEmpty()
title: string
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
description?: string
@Field(() => Number)
@IsNumber()
@IsNotEmpty()
price: number
}

View File

@ -0,0 +1,36 @@
import { UserModel } from '@/src/module/auth/account/models/user.model'
import { Field, ID, ObjectType } from '@nestjs/graphql'
import { SponsorshipPlan } from '@prisma/generated'
@ObjectType()
export class PlanModel implements SponsorshipPlan {
@Field(() => ID)
id: string
@Field(() => String)
title: string
@Field(() => String, { nullable: true })
description: string
@Field(() => Number)
price: number
@Field(() => UserModel)
channel: UserModel
@Field(() => ID)
channelId: string
@Field(() => ID)
stripeProductId: string
@Field(() => ID)
stripePlanId: string
@Field(() => Date)
public createdAt: Date
@Field(() => Date)
public updatedAt: Date
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PlanService } from './plan.service';
import { PlanResolver } from './plan.resolver';
@Module({
providers: [PlanResolver, PlanService],
})
export class PlanModule {}

View File

@ -0,0 +1,35 @@
import { CreatePlanInput } from '@/src/module/sponsorship/plan/inputs/create-plan.input'
import { PlanModel } from '@/src/module/sponsorship/plan/models/plan.model'
import { Authorized } from '@/src/shared/decorators/authorized.decorator'
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'
import { User } from '@prisma/generated'
import { PlanService } from './plan.service'
import { Authorization } from '@/src/shared/decorators/auth.decorator'
@Resolver('Plan')
export class PlanResolver {
constructor(private readonly planService: PlanService) {}
@Authorization()
@Query(() => [PlanModel], { name: 'findMySponsorshipPlans' })
public findMyPlans(@Authorized() user: User) {
return this.planService.findMyPlans(user)
}
@Authorization()
@Mutation(() => PlanModel, { name: 'createSponsorshipPlan' })
public createPlan(
@Authorized() user: User,
@Args('data') input: CreatePlanInput,
) {
return this.planService.create(user, input)
}
@Authorization()
@Mutation(() => PlanModel, { name: 'removeSponsorshipPlan' })
public removePlan(
@Args('planId') planId: string,
) {
return this.planService.remove(planId)
}
}

View File

@ -0,0 +1,79 @@
import { PrismaService } from '@/src/core/prisma/prisma.service'
import { StripeService } from '@/src/module/libs/stripe/stripe.service'
import { CreatePlanInput } from '@/src/module/sponsorship/plan/inputs/create-plan.input'
import { Injectable, NotFoundException } from '@nestjs/common'
import { User } from '@prisma/generated'
@Injectable()
export class PlanService {
constructor(
private readonly prismaService: PrismaService,
private readonly stripeService: StripeService,
) {
}
public async findMyPlans(user: User) {
return this.prismaService.sponsorshipPlan.findMany({
where: { channelId: user.id },
})
}
public async create(user: User, input: CreatePlanInput) {
const { description, price, title } = input
const channel = await this.prismaService.user.findUnique({
where: { id: user.id },
})
if (!channel) {
throw new NotFoundException('Канал не найден')
}
if (!channel.isVerified) {
throw new NotFoundException('Создание планов доступно только для верифицированных каналов')
}
const stripePlan = await this.stripeService.plans.create({
amount: Math.round(price * 100),
currency: 'rub',
interval: 'month',
product: {
name: title,
},
})
return this.prismaService.sponsorshipPlan.create({
data: {
description,
price,
title,
// @ts-expect-error TODO
// eslint-disable-next-line @typescript-eslint/no-base-to-string
stripeProductId: stripePlan.product?.toString(),
stripePlanId: stripePlan.id,
channel: {
connect: {
id: channel.id,
},
},
},
})
}
public async remove(planId: string) {
const plan = await this.prismaService.sponsorshipPlan.findUnique({
where: { id: planId },
})
if (!plan) {
throw new NotFoundException('План не найден')
}
await this.stripeService.plans.del(plan.stripePlanId)
await this.stripeService.products.del(plan.stripeProductId)
return this.prismaService.sponsorshipPlan.delete({
where: { id: plan.id },
})
}
}

View File

@ -0,0 +1,39 @@
import { UserModel } from '@/src/module/auth/account/models/user.model';
import { Field, ID, ObjectType } from '@nestjs/graphql'
import { SponsorshipSubscription } from '@/prisma/generated'
import { PlanModel } from '../../plan/models/plan.model'
@ObjectType()
export class SubscriptionModel implements SponsorshipSubscription {
@Field(() => ID)
public id: string
@Field(() => Date)
public expiresAt: Date
@Field(() => PlanModel)
public plan: PlanModel
@Field(() => String)
public planId: string
@Field(() => UserModel)
public user: UserModel
@Field(() => String)
public userId: string
@Field(() => UserModel)
public channel: UserModel
@Field(() => String)
public channelId: string
@Field(() => Date)
public createdAt: Date
@Field(() => Date)
public updatedAt: Date
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { SubscriptionService } from './subscription.service';
import { SubscriptionResolver } from './subscription.resolver';
@Module({
providers: [SubscriptionResolver, SubscriptionService],
})
export class SubscriptionModule {}

View File

@ -0,0 +1,17 @@
import { SubscriptionModel } from '@/src/module/sponsorship/subscription/model/subscription.model';
import { Authorized } from '@/src/shared/decorators/authorized.decorator'
import { Query, Resolver } from '@nestjs/graphql'
import { User } from '@prisma/generated'
import { SubscriptionService } from './subscription.service'
import { Authorization } from '@/src/shared/decorators/auth.decorator'
@Resolver('Subscription')
export class SubscriptionResolver {
constructor(private readonly subscriptionService: SubscriptionService) {}
@Authorization()
@Query(() => [SubscriptionModel])
public async findMySponsors(@Authorized() user: User) {
return this.subscriptionService.findMySponsors(user)
}
}

View File

@ -0,0 +1,23 @@
import { PrismaService } from '@/src/core/prisma/prisma.service'
import { Injectable } from '@nestjs/common'
import { User } from '@prisma/generated'
@Injectable()
export class SubscriptionService {
constructor(private readonly prismaService: PrismaService) {
}
public async findMySponsors(user: User) {
return this.prismaService.sponsorshipSubscription.findMany({
where: { channelId: user.id },
orderBy: {
createdAt: 'desc',
},
include: {
plan: true,
user: true,
channel: true
},
})
}
}

View File

@ -0,0 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql'
@ObjectType()
export class MakePaymentModel {
@Field(() => String)
url: string
}

View File

@ -0,0 +1,37 @@
import { UserModel } from '@/src/module/auth/account/models/user.model'
import { Field, ID, ObjectType, registerEnumType } from '@nestjs/graphql'
import { Transaction, TransactionStatus } from '@prisma/generated'
registerEnumType(TransactionStatus, {
name: 'TransactionStatus',
})
@ObjectType()
export class TransactionModel implements Transaction {
@Field(() => ID)
public id: string
@Field(() => ID)
stripeSubscriptionId: string
@Field(() => TransactionStatus)
status: TransactionStatus
@Field(() => String)
currency: string
@Field(() => Number)
amount: number
@Field(() => UserModel)
user: UserModel
@Field(() => ID)
userId: string
@Field(() => Date)
public createdAt: Date
@Field(() => Date)
public updatedAt: Date
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { TransactionService } from './transaction.service';
import { TransactionResolver } from './transaction.resolver';
@Module({
providers: [TransactionResolver, TransactionService],
})
export class TransactionModule {}

View File

@ -0,0 +1,27 @@
import { MakePaymentModel } from '@/src/module/sponsorship/transaction/model/make-payment.model'
import { Authorized } from '@/src/shared/decorators/authorized.decorator'
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'
import { User } from '@prisma/generated'
import { TransactionService } from './transaction.service'
import { Authorization } from '@/src/shared/decorators/auth.decorator'
import { TransactionModel } from './model/transaction.model'
@Resolver('Transaction')
export class TransactionResolver {
constructor(private readonly transactionService: TransactionService) {}
@Authorization()
@Query(() => [TransactionModel], { name: 'findMyTransactions' })
public async findMyTransactions(@Authorized() user: User) {
return this.transactionService.findMyTransactions(user)
}
@Authorization()
@Mutation(() => MakePaymentModel, { name: 'makePayment' })
public async makePayment(
@Authorized() user: User,
@Args('planId') planId: string,
) {
return this.transactionService.makePayment(user, planId)
}
}

View File

@ -0,0 +1,96 @@
import { PrismaService } from '@/src/core/prisma/prisma.service'
import { StripeService } from '@/src/module/libs/stripe/stripe.service'
import { ProcessEnv } from '@/src/shared/types/env'
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { User } from '@prisma/generated'
@Injectable()
export class TransactionService {
constructor(
private readonly prismaService: PrismaService,
private readonly configService: ConfigService<ProcessEnv>,
private readonly stripeService: StripeService,
) {
}
public async findMyTransactions(user: User) {
return this.prismaService.transaction.findMany({
where: { userId: user.id },
})
}
public async makePayment(user: User, planId: string) {
const plan = await this.prismaService.sponsorshipPlan.findUnique({
where: { id: planId },
include: { channel: true },
})
if (!plan) {
throw new NotFoundException('Не найден тарифный план')
}
if (user.id === plan.channel?.id) {
throw new ConflictException('Нельзя оформить подписку на себя')
}
const existingSubscription = await this.prismaService.sponsorshipSubscription.findFirst({
where: {
userId: user.id,
channelId: plan.channel?.id,
},
})
if (existingSubscription) {
throw new ConflictException('Вы уже оформили спонсорство на этот канал')
}
const customer = await this.stripeService.customers.create({
name: user.name,
email: user.email,
})
const session = await this.stripeService.checkout.sessions.create({
// @ts-expect-error TODO
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: 'rub',
product_data: {
name: plan.title,
description: plan.description,
},
unit_amount: Math.round(plan.price * 100),
recurring: {
interval: 'month',
},
},
quantity: 1,
},
],
mode: 'subscription',
success_url: `${this.configService.getOrThrow('ALLOWED_ORIGIN')}/success?price=${plan.price}&name=${plan.channel?.name}`,
cancel_url: this.configService.getOrThrow('ALLOWED_ORIGIN'),
customer: customer.id,
metadata: {
planId: plan.id,
userId: user.id,
channelId: plan.channel?.id,
},
})
await this.prismaService.transaction.create({
data: {
amount: plan.price,
currency: session.currency!,
stripeSubscriptionId: session.id,
user: {
connect: { id: user.id },
},
},
})
return { url: session.url }
}
}

View File

@ -1,4 +1,4 @@
import { Controller, HttpCode, HttpStatus, Post, UnauthorizedException, Headers, Body } from '@nestjs/common' import { Controller, HttpCode, HttpStatus, Post, UnauthorizedException, Headers, Body, RawBody } from '@nestjs/common'
import { WebhookService } from './webhook.service' import { WebhookService } from './webhook.service'
@Controller('webhook') @Controller('webhook')
@ -17,4 +17,18 @@ export class WebhookController {
return this.webhookService.receiveWebhookLiveKit(body, authorization) return this.webhookService.receiveWebhookLiveKit(body, authorization)
} }
@Post('stripe')
@HttpCode(HttpStatus.OK)
public async receiveWebhookStripe(
@RawBody() rawBody: string,
@Headers('stripe-signature') sig: string,
) {
if (!sig) {
throw new UnauthorizedException('Отсутствует подпись Stripe в заголовке')
}
const event = await this.webhookService.constructStripeEvent(rawBody, sig)
await this.webhookService.receiveWebhookStripe(event)
}
} }

View File

@ -1,16 +1,23 @@
import { PrismaService } from '@/src/core/prisma/prisma.service' import { PrismaService } from '@/src/core/prisma/prisma.service'
import { LiveKitService } from '@/src/module/libs/livekit/livekit.service' import { LiveKitService } from '@/src/module/libs/livekit/livekit.service'
import { TelegramService } from '@/src/module/libs/telegram/telegram.service'; import { StripeService } from '@/src/module/libs/stripe/stripe.service'
import { TelegramService } from '@/src/module/libs/telegram/telegram.service'
import { NotificationService } from '@/src/module/notification/notification.service' import { NotificationService } from '@/src/module/notification/notification.service'
import { Injectable } from '@nestjs/common' import { ProcessEnv } from '@/src/shared/types/env';
import { BadRequestException, Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { TransactionStatus } from '@prisma/generated'
import Stripe from 'stripe'
@Injectable() @Injectable()
export class WebhookService { export class WebhookService {
constructor( constructor(
private prismaService: PrismaService, private readonly prismaService: PrismaService,
private liveKitService: LiveKitService, private readonly liveKitService: LiveKitService,
private readonly notificationService: NotificationService, private readonly notificationService: NotificationService,
private readonly telegramService: TelegramService, private readonly telegramService: TelegramService,
private readonly stripeService: StripeService,
private readonly configService: ConfigService<ProcessEnv>,
) { ) {
} }
@ -60,4 +67,87 @@ export class WebhookService {
}) })
} }
} }
public async receiveWebhookStripe(event: Stripe.Event) {
const session = event.data.object as Stripe.Checkout.Session
if (!session) {
throw new BadRequestException('Не найдена сессия')
}
if (event.type === 'checkout.session.completed') {
const planId = session.metadata!.planId
const userId = session.metadata!.userId
const channelId = session.metadata!.channelId
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 30)
const sponsorshipSubscription = await this.prismaService.sponsorshipSubscription.create({
data: {
expiresAt,
planId,
userId,
channelId,
},
include: {
plan: true,
user: true,
channel: {
include: {
notificationSettings: true,
},
},
},
})
await this.prismaService.transaction.updateMany({
where: {
stripeSubscriptionId: session.id,
status: TransactionStatus.PENDING,
},
data: {
status: TransactionStatus.SUCCESS,
},
})
if (sponsorshipSubscription.channel?.notificationSettings?.siteNotifications) {
await this.notificationService.createNewSponsorship(
sponsorshipSubscription.channel.id,
sponsorshipSubscription.plan!,
sponsorshipSubscription.user!,
)
}
if (sponsorshipSubscription.channel?.notificationSettings?.telegramNotifications && sponsorshipSubscription.channel.telegramId) {
await this.telegramService.sendNewSponsorship(
sponsorshipSubscription.channel.telegramId,
sponsorshipSubscription.plan!,
sponsorshipSubscription.user!,
)
}
}
if (event.type === 'checkout.session.expired') {
await this.prismaService.transaction.updateMany({
where: { stripeSubscriptionId: session.id },
data: { status: TransactionStatus.EXPIRED },
})
}
if (event.type === 'checkout.session.async_payment_failed') {
await this.prismaService.transaction.updateMany({
where: { stripeSubscriptionId: session.id },
data: { status: TransactionStatus.FAILED },
})
}
}
public constructStripeEvent(payload: any, signature: any) {
return this.stripeService.webhooks.constructEvent(
payload,
signature,
this.configService.getOrThrow('STRIPE_WEBHOOK_SECRET'),
)
}
} }

View File

@ -47,4 +47,6 @@ export interface ProcessEnv {
LIVEKIT_API_SECRET: string LIVEKIT_API_SECRET: string
TELEGRAM_BOT_TOKEN: string TELEGRAM_BOT_TOKEN: string
STRIPE_SECRET_KEY: string
STRIPE_WEBHOOK_SECRET: string
} }

File diff suppressed because it is too large Load Diff