diff --git a/apps/bot/src/handlers/purchase-subscription.ts b/apps/bot/src/handlers/purchase-subscription.ts new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json index c963bfc..7892c55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -594,7 +594,6 @@ "version": "6.11.1", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.11.1.tgz", "integrity": "sha512-z6rCTQN741wxDq82cpdzx2uVykpnQIXalLhrWQSR0jlBVOxCIkz3HZnd8ern3uYTcWKfB3IpVAF7K2FU8t/8AQ==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "jiti": "2.4.2" @@ -604,14 +603,18 @@ "version": "6.11.1", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.11.1.tgz", "integrity": "sha512-lWRb/YSWu8l4Yum1UXfGLtqFzZkVS2ygkWYpgkbgMHn9XJlMITIgeMvJyX5GepChzhmxuSuiq/MY/kGFweOpGw==", - "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/dmmf": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@prisma/dmmf/-/dmmf-6.11.1.tgz", + "integrity": "sha512-EwPSlhz7Qt5msepoxlKIGjtGP6fUEFQyxrYx6f0IdvQ8l0rLSJEqBrTiMpNhgKQFeR8kuiQTv+xzHk/l8mV+Xw==", "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "6.11.1", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.11.1.tgz", "integrity": "sha512-6eKEcV6V8W2eZAUwX2xTktxqPM4vnx3sxz3SDtpZwjHKpC6lhOtc4vtAtFUuf5+eEqBk+dbJ9Dcaj6uQU+FNNg==", - "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -625,14 +628,12 @@ "version": "6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9.tgz", "integrity": "sha512-swFJTOOg4tHyOM1zB/pHb3MeH0i6t7jFKn5l+ZsB23d9AQACuIRo9MouvuKGvnDogzkcjbWnXi/NvOZ0+n5Jfw==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "6.11.1", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.11.1.tgz", "integrity": "sha512-NBYzmkXTkj9+LxNPRSndaAeALOL1Gr3tjvgRYNqruIPlZ6/ixLeuE/5boYOewant58tnaYFZ5Ne0jFBPfGXHpQ==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.11.1", @@ -640,11 +641,27 @@ "@prisma/get-platform": "6.11.1" } }, + "node_modules/@prisma/generator": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@prisma/generator/-/generator-6.11.1.tgz", + "integrity": "sha512-LNpsWyVjebWbVIHVw0bTTQUIlnK+92naGB5fWnjEJqJcxXuDnNQ0XomZAQ1BrqwbMrYL6RYS4whkTpKtu1ijLQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/generator-helper": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-6.11.1.tgz", + "integrity": "sha512-mxdeBqW8inKkuE2nHLo0SsdzsjY51cyRQYgD1uEshvy1/n5eXJJOhEA9T948qA0ZBYUb8SppO2HZtQjwkGwzjQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.11.1", + "@prisma/dmmf": "6.11.1", + "@prisma/generator": "6.11.1" + } + }, "node_modules/@prisma/get-platform": { "version": "6.11.1", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.11.1.tgz", "integrity": "sha512-b2Z8oV2gwvdCkFemBTFd0x4lsL4O2jLSx8lB7D+XqoFALOQZPa7eAPE1NU0Mj1V8gPHRxIsHnyUNtw2i92psUw==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.11.1" @@ -662,14 +679,39 @@ "resolved": "packages/eslint-config", "link": true }, + "node_modules/@repo/exceptions": { + "resolved": "packages/exceptions", + "link": true + }, "node_modules/@repo/logger": { "resolved": "packages/logger", "link": true }, + "node_modules/@repo/services": { + "resolved": "packages/services", + "link": true + }, "node_modules/@repo/typescript-config": { "resolved": "packages/typescript-config", "link": true }, + "node_modules/@repo/utils": { + "resolved": "packages/utils", + "link": true + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.2.tgz", + "integrity": "sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -1490,6 +1532,12 @@ "node": ">=0.8" } }, + "node_modules/code-block-writer": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-12.0.0.tgz", + "integrity": "sha512-q4dMFMlXtKR3XNBHyMHt/3pwYNA69EDk00lloMOaaUMKPUXBw6lpXtbu3MMVG6/uOihGnRDOlkyqsONEUj60+w==", + "license": "MIT" + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3126,7 +3174,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", - "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -3241,6 +3288,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3351,6 +3404,18 @@ "dev": true, "license": "MIT" }, + "node_modules/neverthrow": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/neverthrow/-/neverthrow-8.2.0.tgz", + "integrity": "sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "^4.24.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -3686,7 +3751,6 @@ "version": "6.11.1", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.11.1.tgz", "integrity": "sha512-VzJToRlV0s9Vu2bfqHiRJw73hZNCG/AyJeX+kopbu4GATTjTUdEWUteO3p4BLYoHpMS4o8pD3v6tF44BHNZI1w==", - "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -3708,6 +3772,43 @@ } } }, + "node_modules/prisma-json-types-generator": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/prisma-json-types-generator/-/prisma-json-types-generator-3.5.1.tgz", + "integrity": "sha512-PKhsAxs6IYx/BD6vomsfm0SZQ3wSUDb3GKof7o/CaAJ7kCLmHub6/qJQfD+qwve3omoSrp9kQ29Qj2T980PEUA==", + "license": "MIT", + "dependencies": { + "@prisma/generator-helper": "^6.11.0", + "semver": "^7.7.2", + "tslib": "^2.8.1" + }, + "bin": { + "prisma-json-types-generator": "index.js" + }, + "engines": { + "node": ">=14.0" + }, + "funding": { + "url": "https://github.com/arthurfiorette/prisma-json-types-generator?sponsor=1" + }, + "peerDependencies": { + "@prisma/client": "~6.11", + "prisma": "~6.11", + "typescript": "^5.8" + } + }, + "node_modules/prisma-json-types-generator/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4452,6 +4553,12 @@ "node": ">=0.10.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/turbo": { "version": "2.5.4", "resolved": "https://registry.npmjs.org/turbo/-/turbo-2.5.4.tgz", @@ -4649,7 +4756,6 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -4938,6 +5044,25 @@ "zod": "^3.21.4" } }, + "node_modules/zod-prisma-types": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/zod-prisma-types/-/zod-prisma-types-3.2.4.tgz", + "integrity": "sha512-S4spVBMJAmecLv+aLyRhXK26qW9nWwcsOf1H1fRcEmiI8DPbftZ99u0fhqHlymuTpmMcUpuKxcNNOIqNY0ScSQ==", + "license": "MIT", + "dependencies": { + "@prisma/generator-helper": "^6.3.0", + "code-block-writer": "^12.0.0", + "lodash": "^4.17.21", + "zod": "^3.24.1" + }, + "bin": { + "zod-prisma-types": "dist/bin.js" + }, + "peerDependencies": { + "@prisma/client": "^4.x.x || ^5.x.x || ^6.x.x", + "prisma": "^4.x.x || ^5.x.x || ^6.x.x" + } + }, "packages/config": { "name": "@repo/config", "version": "0.0.0", @@ -4954,7 +5079,10 @@ "name": "@repo/db", "version": "0.0.0", "dependencies": { - "@prisma/client": "^6.11.1" + "@prisma/client": "^6.11.1", + "@repo/exceptions": "*", + "prisma-json-types-generator": "^3.5.1", + "zod-prisma-types": "^3.2.4" }, "devDependencies": { "@types/node": "^24.0.10", @@ -4978,6 +5106,27 @@ "typescript-eslint": "^8.35.0" } }, + "packages/exceptions": { + "name": "@repo/exceptions", + "version": "0.0.0", + "dependencies": { + "neverthrow": "^8.2.0", + "zod": "^4.0.2" + }, + "devDependencies": { + "@repo/typescript-config": "*", + "typescript": "^5.0.0" + } + }, + "packages/exceptions/node_modules/zod": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.2.tgz", + "integrity": "sha512-X2niJNY54MGam4L6Kj0AxeedeDIi/E5QFW0On2faSX5J4/pfLk1tW+cRMIMoojnCavn/u5W/kX17e1CSGnKMxA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/logger": { "name": "@repo/logger", "devDependencies": { @@ -4985,10 +5134,42 @@ "typescript": "latest" } }, + "packages/services": { + "name": "@repo/services", + "version": "0.0.0", + "dependencies": { + "@repo/db": "*", + "@repo/exceptions": "*", + "@repo/utils": "*", + "neverthrow": "^8.2.0", + "zod": "^4.0.2" + }, + "devDependencies": { + "@repo/typescript-config": "*", + "typescript": "^5.0.0" + } + }, + "packages/services/node_modules/zod": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.2.tgz", + "integrity": "sha512-X2niJNY54MGam4L6Kj0AxeedeDIi/E5QFW0On2faSX5J4/pfLk1tW+cRMIMoojnCavn/u5W/kX17e1CSGnKMxA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/typescript-config": { "name": "@repo/typescript-config", "version": "0.0.0", "license": "MIT" + }, + "packages/utils": { + "name": "@repo/utils", + "devDependencies": { + "@repo/exceptions": "*", + "@repo/typescript-config": "*", + "typescript": "latest" + } } } } diff --git a/packages/database/generated/zod/index.ts b/packages/database/generated/zod/index.ts new file mode 100644 index 0000000..d82a66e --- /dev/null +++ b/packages/database/generated/zod/index.ts @@ -0,0 +1,192 @@ +import { z } from 'zod'; +import { Prisma } from '../prisma'; + +///////////////////////////////////////// +// HELPER FUNCTIONS +///////////////////////////////////////// + +// JSON +//------------------------------------------------------ + +export type NullableJsonInput = Prisma.JsonValue | null | 'JsonNull' | 'DbNull' | Prisma.NullTypes.DbNull | Prisma.NullTypes.JsonNull; + +export const transformJsonNull = (v?: NullableJsonInput) => { + if (!v || v === 'DbNull') return Prisma.DbNull; + if (v === 'JsonNull') return Prisma.JsonNull; + return v; +}; + +export const JsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number(), + z.boolean(), + z.literal(null), + z.record(z.lazy(() => JsonValueSchema.optional())), + z.array(z.lazy(() => JsonValueSchema)), + ]) +); + +export type JsonValueType = z.infer; + +export const NullableJsonValue = z + .union([JsonValueSchema, z.literal('DbNull'), z.literal('JsonNull')]) + .nullable() + .transform((v) => transformJsonNull(v)); + +export type NullableJsonValueType = z.infer; + +export const InputJsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number(), + z.boolean(), + z.object({ toJSON: z.function(z.tuple([]), z.any()) }), + z.record(z.lazy(() => z.union([InputJsonValueSchema, z.literal(null)]))), + z.array(z.lazy(() => z.union([InputJsonValueSchema, z.literal(null)]))), + ]) +); + +export type InputJsonValueType = z.infer; + +// DECIMAL +//------------------------------------------------------ + +export const DecimalJsLikeSchema: z.ZodType = z.object({ + d: z.array(z.number()), + e: z.number(), + s: z.number(), + toFixed: z.function(z.tuple([]), z.string()), +}) + +export const DECIMAL_STRING_REGEX = /^(?:-?Infinity|NaN|-?(?:0[bB][01]+(?:\.[01]+)?(?:[pP][-+]?\d+)?|0[oO][0-7]+(?:\.[0-7]+)?(?:[pP][-+]?\d+)?|0[xX][\da-fA-F]+(?:\.[\da-fA-F]+)?(?:[pP][-+]?\d+)?|(?:\d+|\d*\.\d+)(?:[eE][-+]?\d+)?))$/; + +export const isValidDecimalInput = + (v?: null | string | number | Prisma.DecimalJsLike): v is string | number | Prisma.DecimalJsLike => { + if (v === undefined || v === null) return false; + return ( + (typeof v === 'object' && 'd' in v && 'e' in v && 's' in v && 'toFixed' in v) || + (typeof v === 'string' && DECIMAL_STRING_REGEX.test(v)) || + typeof v === 'number' + ) + }; + +///////////////////////////////////////// +// ENUMS +///////////////////////////////////////// + +export const TransactionIsolationLevelSchema = z.enum(['ReadUncommitted','ReadCommitted','RepeatableRead','Serializable']); + +export const TelegramSessionScalarFieldEnumSchema = z.enum(['id','data','createdAt','updatedAt']); + +export const TelegramUserScalarFieldEnumSchema = z.enum(['id','username','isBlocked','firstName','lastName','languageCode','isPremium','createdAt','updatedAt']); + +export const PaymentScalarFieldEnumSchema = z.enum(['id','telegramUserId','amount','currency','status','provider','providerId','metadata','createdAt','updatedAt']); + +export const PlanScalarFieldEnumSchema = z.enum(['id','code','name','description','durationDays','dailyGenerations','price','currency']); + +export const SubscriptionScalarFieldEnumSchema = z.enum(['id','telegramUserId','planId','startedAt','expiresAt','canceledAt','createdAt','updatedAt']); + +export const SortOrderSchema = z.enum(['asc','desc']); + +export const JsonNullValueInputSchema = z.enum(['JsonNull',]).transform((value) => (value === 'JsonNull' ? Prisma.JsonNull : value)); + +export const NullableJsonNullValueInputSchema = z.enum(['DbNull','JsonNull',]).transform((value) => value === 'JsonNull' ? Prisma.JsonNull : value === 'DbNull' ? Prisma.DbNull : value); + +export const QueryModeSchema = z.enum(['default','insensitive']); + +export const JsonNullValueFilterSchema = z.enum(['DbNull','JsonNull','AnyNull',]).transform((value) => value === 'JsonNull' ? Prisma.JsonNull : value === 'DbNull' ? Prisma.JsonNull : value === 'AnyNull' ? Prisma.AnyNull : value); + +export const NullsOrderSchema = z.enum(['first','last']); + +export const PaymentStatusSchema = z.enum(['PENDING','COMPLETED','FAILED','REFUNDED']); + +export type PaymentStatusType = `${z.infer}` + +///////////////////////////////////////// +// MODELS +///////////////////////////////////////// + +///////////////////////////////////////// +// TELEGRAM SESSION SCHEMA +///////////////////////////////////////// + +export const TelegramSessionSchema = z.object({ + id: z.string(), + data: JsonValueSchema, + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}) + +export type TelegramSession = z.infer + +///////////////////////////////////////// +// TELEGRAM USER SCHEMA +///////////////////////////////////////// + +export const TelegramUserSchema = z.object({ + id: z.string(), + username: z.string().nullable(), + isBlocked: z.boolean(), + firstName: z.string().nullable(), + lastName: z.string().nullable(), + languageCode: z.string().nullable(), + isPremium: z.boolean().nullable(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}) + +export type TelegramUser = z.infer + +///////////////////////////////////////// +// PAYMENT SCHEMA +///////////////////////////////////////// + +export const PaymentSchema = z.object({ + status: PaymentStatusSchema, + id: z.string().cuid(), + telegramUserId: z.string(), + amount: z.instanceof(Prisma.Decimal, { message: "Field 'amount' must be a Decimal. Location: ['Models', 'Payment']"}), + currency: z.string(), + provider: z.string(), + providerId: z.string().nullable(), + metadata: JsonValueSchema.nullable(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}) + +export type Payment = z.infer + +///////////////////////////////////////// +// PLAN SCHEMA +///////////////////////////////////////// + +export const PlanSchema = z.object({ + id: z.string().uuid(), + code: z.string(), + name: z.string(), + description: z.string().nullable(), + durationDays: z.number().int(), + dailyGenerations: z.number().int(), + price: z.number().int(), + currency: z.string(), +}) + +export type Plan = z.infer + +///////////////////////////////////////// +// SUBSCRIPTION SCHEMA +///////////////////////////////////////// + +export const SubscriptionSchema = z.object({ + id: z.string().uuid(), + telegramUserId: z.string(), + planId: z.string(), + startedAt: z.coerce.date(), + expiresAt: z.coerce.date(), + canceledAt: z.coerce.date().nullable(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}) + +export type Subscription = z.infer diff --git a/packages/database/package.json b/packages/database/package.json index e4e144b..084ba12 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -8,7 +8,10 @@ "db:push": "prisma db push" }, "dependencies": { - "@prisma/client": "^6.11.1" + "@prisma/client": "^6.11.1", + "@repo/exceptions": "*", + "prisma-json-types-generator": "^3.5.1", + "zod-prisma-types": "^3.2.4" }, "devDependencies": { "@types/node": "^24.0.10", diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 3413e72..db7021c 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -9,6 +9,22 @@ generator client { output = "../generated/prisma" } +generator zod { + provider = "zod-prisma-types" + output = "../generated/zod" + writeBarrelFiles = false // default is true + createInputTypes = false // default is true + createModelTypes = true // default is true + addInputTypeValidation = false // default is true + addIncludeType = false // default is true + addSelectType = false // default is true + validateWhereUniqueInput = false // default is true +} + +generator json { + provider = "prisma-json-types-generator" +} + datasource db { provider = "postgresql" url = env("DATABASE_URL") @@ -31,4 +47,62 @@ model TelegramUser { isPremium Boolean? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + payments Payment[] + subscription Subscription? +} + +model Payment { + id String @id @default(cuid()) + telegramUserId String + amount Decimal @db.Decimal(10, 2) + currency String + status PaymentStatus @default(PENDING) + provider String + providerId String? + metadata Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + telegramUser TelegramUser @relation(fields: [telegramUserId], references: [id]) +} + +enum PaymentStatus { + PENDING + COMPLETED + FAILED + REFUNDED +} + +model Plan { + id String @id @default(uuid()) + code String @unique() + name String + description String? + durationDays Int + dailyGenerations Int + price Int + currency String + + subscriptions Subscription[] + + @@index([code]) +} + +model Subscription { + id String @id @default(uuid()) + telegramUserId String @unique + planId String + + startedAt DateTime @default(now()) + expiresAt DateTime + canceledAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade) + telegramUser TelegramUser @relation(fields: [telegramUserId], references: [id], onDelete: Cascade) + + @@index([telegramUserId]) + @@index([planId]) } diff --git a/packages/database/src/client.ts b/packages/database/src/client.ts index 837b618..a67e6e5 100644 --- a/packages/database/src/client.ts +++ b/packages/database/src/client.ts @@ -16,3 +16,7 @@ export const prisma = new PrismaClientBase().$extends({ }); export type PrismaClient = typeof prisma; + +export type PrismaClientArg = + | Parameters[0]>[0] + | PrismaClient; diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 60d6154..20c1a4a 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -1,2 +1,7 @@ -export { prisma } from "./client"; // exports instance of prisma -export * from "../generated/prisma"; // exports generated types from prisma +export { prisma, type PrismaClientArg, type PrismaClient } from './client'; +export * from '../generated/prisma'; +export { + PaymentSchema, + TelegramUserSchema, + TelegramSessionSchema, +} from '../generated/zod'; diff --git a/packages/exceptions/package.json b/packages/exceptions/package.json new file mode 100644 index 0000000..c6f8e96 --- /dev/null +++ b/packages/exceptions/package.json @@ -0,0 +1,17 @@ +{ + "name": "@repo/exceptions", + "version": "0.0.0", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "neverthrow": "^8.2.0", + "zod": "^4.0.2" + }, + "devDependencies": { + "@repo/typescript-config": "*", + "typescript": "^5.0.0" + } +} diff --git a/packages/exceptions/src/constructors.ts b/packages/exceptions/src/constructors.ts new file mode 100644 index 0000000..c0dc24d --- /dev/null +++ b/packages/exceptions/src/constructors.ts @@ -0,0 +1,35 @@ +import { AppError, DatabaseError, ErrorCode, ValidationError } from './types'; + +export const createAppError = ( + code: T, + message: string, + details?: Record, + cause?: Error +): AppError => ({ + code, + message, + details, + cause, +}); + +export const createValidationError = ( + field: string, + value: unknown, + message: string +): ValidationError => ({ + code: ErrorCode.VALIDATION_ERROR, + message, + field, + value, +}); + +export const databaseError = ( + operation: string, + cause?: Error +): DatabaseError => + createAppError( + ErrorCode.DATABASE_ERROR, + `Database error during ${operation}`, + undefined, + cause + ); diff --git a/packages/exceptions/src/index.ts b/packages/exceptions/src/index.ts new file mode 100644 index 0000000..16dd391 --- /dev/null +++ b/packages/exceptions/src/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './constructors'; +export * from './utils'; diff --git a/packages/exceptions/src/types.ts b/packages/exceptions/src/types.ts new file mode 100644 index 0000000..465b724 --- /dev/null +++ b/packages/exceptions/src/types.ts @@ -0,0 +1,21 @@ +export enum ErrorCode { + // System errors + DATABASE_ERROR = 'DATABASE_ERROR', + VALIDATION_ERROR = 'VALIDATION_ERROR', + UNKNOWN_ERROR = 'UNKNOWN_ERROR', +} + +export type AppError = { + readonly code: T; + readonly message: string; + readonly details?: Record; + readonly cause?: Error; +}; + +export type ValidationError = AppError & { + readonly code: ErrorCode.VALIDATION_ERROR; + readonly field: string; + readonly value: unknown; +}; + +export type DatabaseError = AppError; diff --git a/packages/exceptions/src/utils/database.ts b/packages/exceptions/src/utils/database.ts new file mode 100644 index 0000000..c70d2a4 --- /dev/null +++ b/packages/exceptions/src/utils/database.ts @@ -0,0 +1,25 @@ +import { ResultAsync, err, ok } from 'neverthrow'; +import { AppError } from '../types'; + +/** + * Wraps a database operation in a Result type to handle errors gracefully + * @param operation - The database operation to execute + * @param errorFactory - Function to create a custom error if the operation fails + * @returns Result containing the operation result or error + */ +export const wrapDbOperation = >( + operation: () => Promise, + errorFactory: (error: unknown) => E +): ResultAsync => { + return ResultAsync.fromPromise(operation(), errorFactory); +}; + +export const wrapDbOperationWithNull = >( + operation: () => Promise, + notFoundError: E, + dbErrorFactory: (error: unknown) => E +): ResultAsync => { + return ResultAsync.fromPromise(operation(), (error) => + dbErrorFactory(error) + ).andThen((result) => (result === null ? err(notFoundError) : ok(result))); +}; diff --git a/packages/exceptions/src/utils/index.ts b/packages/exceptions/src/utils/index.ts new file mode 100644 index 0000000..6ce4641 --- /dev/null +++ b/packages/exceptions/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './database'; +export * from './validation'; diff --git a/packages/exceptions/src/utils/validation.ts b/packages/exceptions/src/utils/validation.ts new file mode 100644 index 0000000..ec68b7f --- /dev/null +++ b/packages/exceptions/src/utils/validation.ts @@ -0,0 +1,10 @@ +import { err, ok, Result } from 'neverthrow'; +import { output, ZodError, ZodObject } from 'zod'; + +export const safeParse = + (schema: T) => + (data: D): Result, ZodError>> => { + const parseResult = schema.safeParse(data); + + return parseResult.success ? ok(parseResult.data) : err(parseResult.error); + }; diff --git a/packages/services/package.json b/packages/services/package.json new file mode 100644 index 0000000..1185738 --- /dev/null +++ b/packages/services/package.json @@ -0,0 +1,20 @@ +{ + "name": "@repo/services", + "version": "0.0.0", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@repo/db": "*", + "@repo/exceptions": "*", + "@repo/utils": "*", + "neverthrow": "^8.2.0", + "zod": "^4.0.2" + }, + "devDependencies": { + "@repo/typescript-config": "*", + "typescript": "^5.0.0" + } +} diff --git a/packages/services/src/index.ts b/packages/services/src/index.ts new file mode 100644 index 0000000..6351060 --- /dev/null +++ b/packages/services/src/index.ts @@ -0,0 +1 @@ +export * from './payment'; diff --git a/packages/services/src/payment/errors.ts b/packages/services/src/payment/errors.ts new file mode 100644 index 0000000..56bad74 --- /dev/null +++ b/packages/services/src/payment/errors.ts @@ -0,0 +1,20 @@ +import { AppError, createAppError } from '@repo/exceptions'; + +export enum PaymentErrorCode { + PAYMENT_NOT_FOUND = 'PAYMENT_NOT_FOUND', + PAYMENT_ALREADY_PROCESSED = 'PAYMENT_ALREADY_PROCESSED', +} + +export type PaymentError = AppError; + +export const paymentNotFound = (paymentId: string): PaymentError => + createAppError( + PaymentErrorCode.PAYMENT_NOT_FOUND, + `Payment with id ${paymentId} not found` + ) as PaymentError; + +export const paymentAlreadyProcessed = (paymentId: string): PaymentError => + createAppError( + PaymentErrorCode.PAYMENT_ALREADY_PROCESSED, + `Payment ${paymentId} has already been processed` + ) as PaymentError; diff --git a/packages/services/src/payment/index.ts b/packages/services/src/payment/index.ts new file mode 100644 index 0000000..0759a18 --- /dev/null +++ b/packages/services/src/payment/index.ts @@ -0,0 +1,4 @@ +export * from './errors'; +export * from './types'; +export * from './repository'; +export * from './service'; diff --git a/packages/services/src/payment/repository.ts b/packages/services/src/payment/repository.ts new file mode 100644 index 0000000..a49e9eb --- /dev/null +++ b/packages/services/src/payment/repository.ts @@ -0,0 +1,61 @@ +import { PaymentStatus, Prisma, PrismaClientArg, Payment } from '@repo/db'; +import { paymentNotFound, PaymentError } from './errors'; +import { + DatabaseError, + databaseError, + wrapDbOperation, + wrapDbOperationWithNull, +} from '@repo/exceptions'; +import { ResultAsync } from 'neverthrow'; + +export const PaymentRepository = { + create( + client: PrismaClientArg, + data: Prisma.PaymentUncheckedCreateInput + ): ResultAsync { + return wrapDbOperation( + () => client.payment.create({ data }).then((payment: Payment) => payment), + (e) => databaseError('create-error', e as Error) + ); + }, + + findById( + client: PrismaClientArg, + id: string + ): ResultAsync { + return wrapDbOperationWithNull( + () => client.payment.findUnique({ where: { id } }), + paymentNotFound(id), + (e) => databaseError('find-by-id', e as Error) + ); + }, + + updateStatus( + client: PrismaClientArg, + id: string, + status: PaymentStatus + ): ResultAsync { + return wrapDbOperation( + () => + client.payment.update({ + where: { id }, + data: { status }, + }), + () => databaseError(id) + ); + }, + + findByUserId( + client: PrismaClientArg, + telegramUserId: string + ): ResultAsync { + return wrapDbOperation( + () => + client.payment.findMany({ + where: { telegramUserId }, + orderBy: { createdAt: 'desc' }, + }), + () => databaseError(`user-${telegramUserId}`) + ); + }, +}; diff --git a/packages/services/src/payment/service.ts b/packages/services/src/payment/service.ts new file mode 100644 index 0000000..0965bc6 --- /dev/null +++ b/packages/services/src/payment/service.ts @@ -0,0 +1,35 @@ +import { ok, err } from 'neverthrow'; +import { CreatePaymentInput, CreatePaymentInputSchema } from './types'; +import { paymentAlreadyProcessed } from './errors'; +import { PaymentRepository } from './repository'; +import { Payment, PaymentStatus, PrismaClient } from '@repo/db'; +import { safeParse } from '@repo/exceptions'; + +const validateInput = safeParse(CreatePaymentInputSchema); + +export const createPayment = ( + client: PrismaClient, + input: CreatePaymentInput +) => + validateInput(input).map((validatedInput) => + PaymentRepository.create(client, validatedInput) + ); + +export const getPaymentById = (client: PrismaClient, id: string) => + PaymentRepository.findById(client, id); + +export const getUserPayments = (client: PrismaClient, telegramUserId: string) => + PaymentRepository.findByUserId(client, telegramUserId); + +export const validatePaymentForProcessing = (payment: Payment) => + payment.status === PaymentStatus.PENDING + ? ok(payment) + : err(paymentAlreadyProcessed(payment.id)); + +export const PaymentService = { + validateInput, + createPayment, + getPaymentById, + getUserPayments, + validatePaymentForProcessing, +}; diff --git a/packages/services/src/payment/types.ts b/packages/services/src/payment/types.ts new file mode 100644 index 0000000..1d95a8e --- /dev/null +++ b/packages/services/src/payment/types.ts @@ -0,0 +1,50 @@ +import { Payment, PaymentStatus, PrismaClient } from '@repo/db'; +import { z } from 'zod'; +import { ResultAsync } from 'neverthrow'; +import { PaymentError } from './errors'; + +export enum Currency { + USD = 'USD', + EUR = 'EUR', + RUB = 'RUB', + XTR = 'XTR', +} + +export const CURRENCIES = Object.values(Currency); + +export enum PaymentProvider { + YOOKASSA = 'yookassa', + TELEGRAM = 'telegram', +} + +export const PAYMENT_PROVIDERS = Object.values(PaymentProvider); + +export const CreatePaymentInputSchema = z.object({ + telegramUserId: z.uuid(), + amount: z.number().positive(), + currency: z.enum(Currency), + provider: z.enum(PaymentProvider), +}); + +export type CreatePaymentInput = z.infer; + +export type PaymentProviderResponse = { + readonly paymentUrl: string; + readonly providerId: string; +}; + +export type IPaymentProvider = { + readonly createPayment: ( + client: PrismaClient, + input: CreatePaymentInput + ) => ResultAsync; + readonly verifyPayment: ( + client: PrismaClient, + payment: Payment + ) => ResultAsync; + readonly refundPayment: ( + client: PrismaClient, + providerId: string, + amount?: number + ) => ResultAsync; +}; diff --git a/packages/services/tsconfig.json b/packages/services/tsconfig.json new file mode 100644 index 0000000..76b93f1 --- /dev/null +++ b/packages/services/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": false, + "declarationMap": false + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/turbo.json b/turbo.json index c2f8006..f9d43fe 100644 --- a/turbo.json +++ b/turbo.json @@ -14,6 +14,11 @@ "persistent": true, "cache": false }, + "start": { + "dependsOn": ["^build"], + "persistent": true, + "cache": false + }, "db:generate": { "cache": false },