From 084b10058970f3c0e631b06fb2809099676d7ec7 Mon Sep 17 00:00:00 2001 From: Yuu Ottosoka Date: Fri, 11 Jul 2025 13:54:55 +0300 Subject: [PATCH] feat: implement payment service with error handling and validation Add new payment service with repository, error handling, and validation Add exceptions package with database and validation utilities Update database package with new schema and zod types Add turbo start script for development --- .../bot/src/handlers/purchase-subscription.ts | 0 package-lock.json | 201 +++++++++++++++++- packages/database/generated/zod/index.ts | 192 +++++++++++++++++ packages/database/package.json | 5 +- packages/database/prisma/schema.prisma | 74 +++++++ packages/database/src/client.ts | 4 + packages/database/src/index.ts | 9 +- packages/exceptions/package.json | 17 ++ packages/exceptions/src/constructors.ts | 35 +++ packages/exceptions/src/index.ts | 3 + packages/exceptions/src/types.ts | 21 ++ packages/exceptions/src/utils/database.ts | 25 +++ packages/exceptions/src/utils/index.ts | 2 + packages/exceptions/src/utils/validation.ts | 10 + packages/services/package.json | 20 ++ packages/services/src/index.ts | 1 + packages/services/src/payment/errors.ts | 20 ++ packages/services/src/payment/index.ts | 4 + packages/services/src/payment/repository.ts | 61 ++++++ packages/services/src/payment/service.ts | 35 +++ packages/services/src/payment/types.ts | 50 +++++ packages/services/tsconfig.json | 11 + turbo.json | 5 + 23 files changed, 792 insertions(+), 13 deletions(-) create mode 100644 apps/bot/src/handlers/purchase-subscription.ts create mode 100644 packages/database/generated/zod/index.ts create mode 100644 packages/exceptions/package.json create mode 100644 packages/exceptions/src/constructors.ts create mode 100644 packages/exceptions/src/index.ts create mode 100644 packages/exceptions/src/types.ts create mode 100644 packages/exceptions/src/utils/database.ts create mode 100644 packages/exceptions/src/utils/index.ts create mode 100644 packages/exceptions/src/utils/validation.ts create mode 100644 packages/services/package.json create mode 100644 packages/services/src/index.ts create mode 100644 packages/services/src/payment/errors.ts create mode 100644 packages/services/src/payment/index.ts create mode 100644 packages/services/src/payment/repository.ts create mode 100644 packages/services/src/payment/service.ts create mode 100644 packages/services/src/payment/types.ts create mode 100644 packages/services/tsconfig.json 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 },