add sponsorship, stripe
This commit is contained in:
parent
5b6d8cccb7
commit
bfe3d9573c
@ -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,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// ];
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -72,30 +72,34 @@ model SocialLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
password String
|
password String
|
||||||
name String @unique
|
name String @unique
|
||||||
displayName String @map("display_name")
|
displayName String @map("display_name")
|
||||||
avatar String?
|
avatar String?
|
||||||
bio String?
|
bio String?
|
||||||
token Token[]
|
token Token[]
|
||||||
isVerified Boolean @default(false) @map("is_verified")
|
isVerified Boolean @default(false) @map("is_verified")
|
||||||
isEmailVerified Boolean @default(false) @map("is_email_verified")
|
isEmailVerified Boolean @default(false) @map("is_email_verified")
|
||||||
isTotpEnabled Boolean @default(false) @map("is_totp_enabled")
|
isTotpEnabled Boolean @default(false) @map("is_totp_enabled")
|
||||||
isDeactivated Boolean @default(false) @map("is_deactivated")
|
isDeactivated Boolean @default(false) @map("is_deactivated")
|
||||||
deactivatedAt DateTime? @map("deactivated_at")
|
deactivatedAt DateTime? @map("deactivated_at")
|
||||||
socialLink SocialLink[]
|
socialLink SocialLink[]
|
||||||
totpSecret String? @map("totp_secret")
|
totpSecret String? @map("totp_secret")
|
||||||
stream Stream?
|
stream Stream?
|
||||||
chatMessages ChatMessage[]
|
chatMessages ChatMessage[]
|
||||||
followers Follow[] @relation(name: "followers")
|
followers Follow[] @relation(name: "followers")
|
||||||
followings Follow[] @relation(name: "followings")
|
followings Follow[] @relation(name: "followings")
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
notificationSettings NotificationSettings?
|
notificationSettings NotificationSettings?
|
||||||
telegramId String? @unique @map("telegram_id")
|
telegramId String? @unique @map("telegram_id")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
transactions Transaction[]
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
sponsorshipPlans SponsorshipPlan[]
|
||||||
|
sponsorshipSubscriptions SponsorshipSubscription[] @relation(name: "sponsorship_subscriptions")
|
||||||
|
sponsors SponsorshipSubscription[] @relation(name: "sponsors")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
|
|||||||
12
backend/src/core/config/stripe.config.ts
Normal file
12
backend/src/core/config/stripe.config.ts
Normal 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'),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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'
|
||||||
@ -24,7 +29,7 @@ import { IS_DEV } from '@/src/shared/util/is-dev.util'
|
|||||||
import { ApolloDriver } from '@nestjs/apollo'
|
import { ApolloDriver } from '@nestjs/apollo'
|
||||||
import { Module } from '@nestjs/common'
|
import { Module } from '@nestjs/common'
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config'
|
import { ConfigModule, ConfigService } from '@nestjs/config'
|
||||||
import { GraphQLModule } from '@nestjs/graphql'
|
import { GraphQLModule } from '@nestjs/graphql'
|
||||||
import { PrismaModule } from './prisma/prisma.module'
|
import { PrismaModule } from './prisma/prisma.module'
|
||||||
import { RedisModule } from './redis/redis.module'
|
import { RedisModule } from './redis/redis.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 {}
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
38
backend/src/module/libs/stripe/stripe.module.ts
Normal file
38
backend/src/module/libs/stripe/stripe.module.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/module/libs/stripe/stripe.service.ts
Normal file
13
backend/src/module/libs/stripe/stripe.service.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/src/module/libs/stripe/types/stripe.type.ts
Normal file
12
backend/src/module/libs/stripe/types/stripe.type.ts
Normal 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'>
|
||||||
@ -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>`,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
36
backend/src/module/sponsorship/plan/models/plan.model.ts
Normal file
36
backend/src/module/sponsorship/plan/models/plan.model.ts
Normal 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
|
||||||
|
}
|
||||||
8
backend/src/module/sponsorship/plan/plan.module.ts
Normal file
8
backend/src/module/sponsorship/plan/plan.module.ts
Normal 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 {}
|
||||||
35
backend/src/module/sponsorship/plan/plan.resolver.ts
Normal file
35
backend/src/module/sponsorship/plan/plan.resolver.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
79
backend/src/module/sponsorship/plan/plan.service.ts
Normal file
79
backend/src/module/sponsorship/plan/plan.service.ts
Normal 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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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 {}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
import { Field, ObjectType } from '@nestjs/graphql'
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class MakePaymentModel {
|
||||||
|
@Field(() => String)
|
||||||
|
url: string
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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 {}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
backend/src/shared/types/env.d.ts
vendored
2
backend/src/shared/types/env.d.ts
vendored
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
1559
backend/yarn.lock
1559
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user