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
This commit is contained in:
Yuu Ottosoka
2025-07-11 13:54:55 +03:00
parent 49f0385d00
commit 084b100589
23 changed files with 792 additions and 13 deletions

201
package-lock.json generated
View File

@@ -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"
}
}
}
}

View File

@@ -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<Prisma.JsonValue> = 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<typeof JsonValueSchema>;
export const NullableJsonValue = z
.union([JsonValueSchema, z.literal('DbNull'), z.literal('JsonNull')])
.nullable()
.transform((v) => transformJsonNull(v));
export type NullableJsonValueType = z.infer<typeof NullableJsonValue>;
export const InputJsonValueSchema: z.ZodType<Prisma.InputJsonValue> = 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<typeof InputJsonValueSchema>;
// DECIMAL
//------------------------------------------------------
export const DecimalJsLikeSchema: z.ZodType<Prisma.DecimalJsLike> = 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<typeof PaymentStatusSchema>}`
/////////////////////////////////////////
// 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<typeof TelegramSessionSchema>
/////////////////////////////////////////
// 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<typeof TelegramUserSchema>
/////////////////////////////////////////
// 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<typeof PaymentSchema>
/////////////////////////////////////////
// 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<typeof PlanSchema>
/////////////////////////////////////////
// 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<typeof SubscriptionSchema>

View File

@@ -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",

View File

@@ -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])
}

View File

@@ -16,3 +16,7 @@ export const prisma = new PrismaClientBase().$extends({
});
export type PrismaClient = typeof prisma;
export type PrismaClientArg =
| Parameters<Parameters<PrismaClient['$transaction']>[0]>[0]
| PrismaClient;

View File

@@ -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';

View File

@@ -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"
}
}

View File

@@ -0,0 +1,35 @@
import { AppError, DatabaseError, ErrorCode, ValidationError } from './types';
export const createAppError = <T>(
code: T,
message: string,
details?: Record<string, unknown>,
cause?: Error
): AppError<T> => ({
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
);

View File

@@ -0,0 +1,3 @@
export * from './types';
export * from './constructors';
export * from './utils';

View File

@@ -0,0 +1,21 @@
export enum ErrorCode {
// System errors
DATABASE_ERROR = 'DATABASE_ERROR',
VALIDATION_ERROR = 'VALIDATION_ERROR',
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
}
export type AppError<T> = {
readonly code: T;
readonly message: string;
readonly details?: Record<string, unknown>;
readonly cause?: Error;
};
export type ValidationError = AppError<ErrorCode.VALIDATION_ERROR> & {
readonly code: ErrorCode.VALIDATION_ERROR;
readonly field: string;
readonly value: unknown;
};
export type DatabaseError = AppError<ErrorCode.DATABASE_ERROR>;

View File

@@ -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 = <T, E extends AppError<unknown>>(
operation: () => Promise<T>,
errorFactory: (error: unknown) => E
): ResultAsync<T, E> => {
return ResultAsync.fromPromise(operation(), errorFactory);
};
export const wrapDbOperationWithNull = <T, E extends AppError<unknown>>(
operation: () => Promise<T | null>,
notFoundError: E,
dbErrorFactory: (error: unknown) => E
): ResultAsync<T, E> => {
return ResultAsync.fromPromise(operation(), (error) =>
dbErrorFactory(error)
).andThen((result) => (result === null ? err(notFoundError) : ok(result)));
};

View File

@@ -0,0 +1,2 @@
export * from './database';
export * from './validation';

View File

@@ -0,0 +1,10 @@
import { err, ok, Result } from 'neverthrow';
import { output, ZodError, ZodObject } from 'zod';
export const safeParse =
<T extends ZodObject>(schema: T) =>
<D>(data: D): Result<output<T>, ZodError<output<T>>> => {
const parseResult = schema.safeParse(data);
return parseResult.success ? ok(parseResult.data) : err(parseResult.error);
};

View File

@@ -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"
}
}

View File

@@ -0,0 +1 @@
export * from './payment';

View File

@@ -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<PaymentErrorCode>;
export const paymentNotFound = (paymentId: string): PaymentError =>
createAppError<PaymentErrorCode>(
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;

View File

@@ -0,0 +1,4 @@
export * from './errors';
export * from './types';
export * from './repository';
export * from './service';

View File

@@ -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<Payment, DatabaseError> {
return wrapDbOperation(
() => client.payment.create({ data }).then((payment: Payment) => payment),
(e) => databaseError('create-error', e as Error)
);
},
findById(
client: PrismaClientArg,
id: string
): ResultAsync<Payment, PaymentError | DatabaseError> {
return wrapDbOperationWithNull<Payment, PaymentError | DatabaseError>(
() => client.payment.findUnique({ where: { id } }),
paymentNotFound(id),
(e) => databaseError('find-by-id', e as Error)
);
},
updateStatus(
client: PrismaClientArg,
id: string,
status: PaymentStatus
): ResultAsync<Payment, DatabaseError> {
return wrapDbOperation(
() =>
client.payment.update({
where: { id },
data: { status },
}),
() => databaseError(id)
);
},
findByUserId(
client: PrismaClientArg,
telegramUserId: string
): ResultAsync<Payment[], DatabaseError> {
return wrapDbOperation(
() =>
client.payment.findMany({
where: { telegramUserId },
orderBy: { createdAt: 'desc' },
}),
() => databaseError(`user-${telegramUserId}`)
);
},
};

View File

@@ -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,
};

View File

@@ -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<typeof CreatePaymentInputSchema>;
export type PaymentProviderResponse = {
readonly paymentUrl: string;
readonly providerId: string;
};
export type IPaymentProvider = {
readonly createPayment: (
client: PrismaClient,
input: CreatePaymentInput
) => ResultAsync<PaymentProviderResponse, PaymentError>;
readonly verifyPayment: (
client: PrismaClient,
payment: Payment
) => ResultAsync<PaymentStatus, PaymentError>;
readonly refundPayment: (
client: PrismaClient,
providerId: string,
amount?: number
) => ResultAsync<boolean, PaymentError>;
};

View File

@@ -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"]
}

View File

@@ -14,6 +14,11 @@
"persistent": true,
"cache": false
},
"start": {
"dependsOn": ["^build"],
"persistent": true,
"cache": false
},
"db:generate": {
"cache": false
},