Quellcode durchsuchen

Добавлена оплата

Vadim vor 3 Monaten
Ursprung
Commit
7eec06fdbc

+ 120 - 0
package-lock.json

@@ -9,6 +9,7 @@
       "version": "1.0.0",
       "license": "ISC",
       "dependencies": {
+        "axios": "^1.9.0",
         "bcrypt": "^5.1.1",
         "cookie-parser": "^ 1.4.7 ",
         "cors": "^2.8.5",
@@ -41,6 +42,7 @@
         "@types/validator": "^13.15.1",
         "@typescript-eslint/eslint-plugin": "^6.21.0",
         "@typescript-eslint/parser": "^6.21.0",
+        "cross-env": "^7.0.3",
         "eslint": "^8.57.1",
         "husky": "^8.0.3",
         "nodemon": "^3.1.9",
@@ -957,6 +959,21 @@
       "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
       "license": "MIT"
     },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+    },
+    "node_modules/axios": {
+      "version": "1.9.0",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
+      "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
+      "dependencies": {
+        "follow-redirects": "^1.15.6",
+        "form-data": "^4.0.0",
+        "proxy-from-env": "^1.1.0"
+      }
+    },
     "node_modules/balanced-match": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1263,6 +1280,17 @@
         "text-hex": "1.0.x"
       }
     },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/concat-map": {
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -1383,6 +1411,24 @@
         "node": ">= 0.10"
       }
     },
+    "node_modules/cross-env": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
+      "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
+      "dev": true,
+      "dependencies": {
+        "cross-spawn": "^7.0.1"
+      },
+      "bin": {
+        "cross-env": "src/bin/cross-env.js",
+        "cross-env-shell": "src/bin/cross-env-shell.js"
+      },
+      "engines": {
+        "node": ">=10.14",
+        "npm": ">=6",
+        "yarn": ">=1"
+      }
+    },
     "node_modules/cross-spawn": {
       "version": "7.0.3",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@@ -1432,6 +1478,14 @@
       "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
       "dev": true
     },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
     "node_modules/delegates": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
@@ -1576,6 +1630,20 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
     "node_modules/esbuild": {
       "version": "0.25.0",
       "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz",
@@ -2084,6 +2152,25 @@
       "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==",
       "license": "MIT"
     },
+    "node_modules/follow-redirects": {
+      "version": "1.15.9",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
+      "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/foreground-child": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
@@ -2100,6 +2187,20 @@
         "url": "https://github.com/sponsors/isaacs"
       }
     },
+    "node_modules/form-data": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
+      "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/forwarded": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -2418,6 +2519,20 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/has-unicode": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
@@ -3616,6 +3731,11 @@
         "node": ">= 0.10"
       }
     },
+    "node_modules/proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+    },
     "node_modules/pstree.remy": {
       "version": "1.1.8",
       "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",

+ 2 - 1
package.json

@@ -29,6 +29,7 @@
   "author": "",
   "license": "ISC",
   "dependencies": {
+    "axios": "^1.9.0",
     "bcrypt": "^5.1.1",
     "cookie-parser": "^ 1.4.7 ",
     "cors": "^2.8.5",
@@ -61,7 +62,7 @@
     "@types/validator": "^13.15.1",
     "@typescript-eslint/eslint-plugin": "^6.21.0",
     "@typescript-eslint/parser": "^6.21.0",
-"cross-env": "^7.0.3",
+    "cross-env": "^7.0.3",
     "eslint": "^8.57.1",
     "husky": "^8.0.3",
     "nodemon": "^3.1.9",

+ 2 - 0
src/api/v_0.1.0/api.ts

@@ -4,6 +4,7 @@ import { clientUsersApi } from "./client/client-users-api.js";
 import { clientEventApi } from "./client/client-event-api.js";
 import { clientActivitiesApi } from "./client/client-activities-api.js";
 import { actTypes } from "./types/act-types.js";
+import { clientShopApi } from "./client/client-shop-api.js";
 
 export const api = {
   auth: authApi,
@@ -13,6 +14,7 @@ export const api = {
     users: clientUsersApi,
     event: clientEventApi,
     activities: clientActivitiesApi,
+    shop: clientShopApi,
   },
 };
 

+ 176 - 0
src/api/v_0.1.0/client/client-shop-api.ts

@@ -0,0 +1,176 @@
+import { z } from "zod";
+import { CustomFieldWithValue } from "../types/custom-fields-types.js";
+import { OrderShema, PaymentShema } from "../types/shop-types.js";
+
+class ClientShopApi {
+  GET_Cart = {
+    req: {
+      params: z.object({
+        cartId: z.string().uuid().optional(),
+      }),
+    },
+    res: z.object({
+      code: z.enum(["success"]),
+      cart: z
+        .object({
+          cartId: z.string().uuid(),
+          items: z.array(
+            z.object({
+              cartItemId: z.string().uuid(),
+              productId: z.string().uuid(),
+              productName: z.string(),
+              quantity: z.number(),
+              productType: z.enum([
+                "SHOP_ORDER",
+                "TICKET",
+                "ACTIVITY_REGISTRATION",
+                "ACTIVITY_PARTICIPANT",
+              ]),
+              priceAtAddition: z.number(),
+              realPrice: z.number(),
+              currencyCode: z.string().length(3),
+              isActive: z.boolean(),
+              attributes: z.any(),
+
+              activityRegId: z.string().uuid().nullable(),
+              activityPublicName: z.string().nullable(),
+              activityRegNumber: z.string().nullable(),
+
+              peMemberId: z.string().uuid().nullable(),
+              peMemberFields: CustomFieldWithValue.extend({
+                userEfId: z.string().uuid(),
+              }).nullable(),
+              addedAt: z.string().datetime(),
+            }),
+          ),
+        })
+        .nullable(),
+    }),
+  };
+
+  POST_CartItem = {
+    req: {
+      params: z.object({
+        cartId: z.string().uuid().optional(),
+      }),
+      body: z.discriminatedUnion("productType", [
+        z.object({
+          productType: z.literal("ACTIVITY_REGISTRATION"),
+          productId: z.string(),
+          activityRegId: z.string(),
+        }),
+        z.object({
+          productType: z.literal("ACTIVITY_PARTICIPANT"),
+          productId: z.string(),
+          peMemberId: z.string(),
+        }),
+        z.object({
+          productType: z.literal("SHOP_ORDER"),
+          productId: z.string(),
+        }),
+      ]),
+    },
+    res: z.object({
+      code: z.enum(["success"]),
+      cartId: z.string().uuid().optional(),
+    }),
+  };
+
+  DELETE_CartItem = {
+    req: {
+      params: z.object({
+        cartItemId: z.string(),
+      }),
+    },
+    res: z.object({
+      code: z.enum(["success"]),
+    }),
+  };
+
+  PUT_CartItemQuantity = {
+    req: {
+      params: z.object({
+        cartItemId: z.string(),
+      }),
+      body: z.object({
+        quantity: z.number(),
+      }),
+    },
+    res: z.object({
+      code: z.enum(["success"]),
+    }),
+  };
+
+  //
+  //
+  // payment
+  POST_Checkout = {
+    req: {
+      body: z.object({
+        cartId: z.string().uuid().optional(),
+        userData: z.object({
+          firstName: z.string(),
+          lastName: z.string(),
+          patronymic: z.string().nullable(),
+          email: z.string().email(),
+          phone: z.string(),
+        }),
+      }),
+    },
+    res: z.object({
+      code: z.enum(["success"]),
+      order: OrderShema,
+      payment: PaymentShema,
+    }),
+  };
+
+  GET_Order = {
+    req: {
+      params: z.object({
+        orderNumber: z.string(),
+      }),
+    },
+    res: z.object({
+      code: z.enum(["success"]),
+      order: OrderShema,
+      payment: PaymentShema,
+    }),
+  };
+
+  GET_Orders = {
+    req: {
+      params: z.object({
+        userId: z.string().uuid(),
+      }),
+    },
+    res: z.object({
+      code: z.enum(["success"]),
+      orders: z.array(OrderShema),
+    }),
+  };
+
+  POST_CancelOrder = {
+    req: {
+      params: z.object({
+        orderId: z.string().uuid(),
+      }),
+    },
+    res: z.object({
+      code: z.enum(["success"]),
+    }),
+  };
+
+  GET_Payment = {
+    req: {
+      params: z.object({
+        orderId: z.string().uuid(),
+      }),
+    },
+    res: z.object({
+      code: z.enum(["success"]),
+      payment: PaymentShema,
+    }),
+  };
+}
+
+export const clientShopApi = new ClientShopApi();

+ 64 - 0
src/api/v_0.1.0/types/shop-types.ts

@@ -0,0 +1,64 @@
+import { z } from "zod";
+import { CustomFieldWithValue } from "./custom-fields-types.js";
+
+export const OrderShema = z.object({
+  orderId: z.string().uuid(),
+  userId: z.string().uuid().nullable(),
+  orderNumber: z.string(),
+  status: z.enum([
+    "DRAFT",
+    "PENDING_PAYMENT",
+    "PAID",
+    "PROCESSING",
+    "COMPLETED",
+    "CANCELLED",
+    "FAILED",
+  ]),
+  totalAmount: z.number(),
+  currencyCode: z.string().length(3),
+  billingDataSnapshot: z.any().nullable(),
+  createdAt: z.string().datetime(),
+  paymentDueDate: z.string().datetime(),
+  items: z.array(
+    z.object({
+      orderItemId: z.string().uuid(),
+      orderId: z.string().uuid(),
+      productId: z.string().uuid(),
+      quantity: z.number(),
+      unitPrice: z.number(),
+      totalPrice: z.number(),
+      activityRegId: z.string().uuid().nullable(),
+      peMemberId: z.string().uuid().nullable(),
+      attributesSnapshot: z.any().nullable(),
+      productName: z.string(),
+      productType: z.enum([
+        "SHOP_ORDER",
+        "TICKET",
+        "ACTIVITY_REGISTRATION",
+        "ACTIVITY_PARTICIPANT",
+      ]),
+      stockQuantity: z.number().nullable(),
+      actPublicName: z.string().nullable(),
+      peMemberFields: CustomFieldWithValue.extend({
+        userEfId: z.string().uuid(),
+      }).nullable(),
+    }),
+  ),
+});
+
+export const PaymentShema = z.object({
+  paymentId: z.string().uuid(),
+  status: z.enum(["PENDING", "SUCCEEDED", "FAILED", "REFUNDED", "CANCELED"]),
+  confirmation: z
+    .object({
+      type: z.enum([
+        "redirect",
+        "qr",
+        "embedded",
+        "external",
+        "mobile_application",
+      ]),
+      confirmationUrl: z.string().url().nullish(),
+    })
+    .nullish(),
+});

+ 120 - 1
src/db/db-schema.ts

@@ -163,7 +163,7 @@ const DbSchema = {
       categoryId: z.string().uuid().nullable(),
       blockId: z.string().uuid().nullable(),
       isUserReg: z.boolean(),
-paymentConfig: z.enum([
+      paymentConfig: z.enum([
         "PER_REGISTRATION",
         "PER_PARTICIPANT",
         "FREE",
@@ -278,6 +278,125 @@ paymentConfig: z.enum([
       errorMessage: z.string().nullable(), // Новое поле
     },
   },
+
+  shop: {
+    productCategories: {
+      categoryId: z.string().uuid(),
+      parentCategoryId: z.string().uuid().nullable(),
+      name: z.string(),
+      description: z.string().nullable(),
+      code: z.string(),
+    },
+    products: {
+      productId: z.string().uuid(),
+      categoryId: z.string().nullable(),
+      productType: z.enum([
+        "SHOP_ORDER",
+        "TICKET",
+        "ACTIVITY_REGISTRATION",
+        "ACTIVITY_PARTICIPANT",
+      ]),
+      sku: z.string().nullable(),
+      name: z.string(),
+      description: z.string().nullable(),
+      price: z.number(),
+      currencyCode: z.string().length(3),
+      stockQuantity: z.number().nullable(),
+      isActive: z.boolean(),
+      attributes: z.any().nullable(),
+      createdAt: z.string().datetime(),
+      updatedAt: z.string().datetime(),
+    },
+    carts: {
+      cartId: z.string().uuid(),
+      userId: z.string().uuid().nullable(),
+      createdAt: z.string().datetime(),
+      updatedAt: z.string().datetime(),
+    },
+    cartItems: {
+      cartItemId: z.string().uuid(),
+      cartId: z.string().uuid(),
+      productId: z.string().uuid(),
+      quantity: z.number(),
+      priceAtAddition: z.number(),
+      activityRegId: z.string().uuid().nullable(),
+      peMemberId: z.string().uuid().nullable(),
+      addedAt: z.string().datetime(),
+      orderId: z.string().uuid().nullable(),
+    },
+    orders: {
+      orderId: z.string().uuid(),
+      userId: z.string().uuid().nullable(),
+      orderNumber: z.string(),
+      status: z.enum([
+        "DRAFT",
+        "PENDING_PAYMENT",
+        "PAID",
+        "PROCESSING",
+        "COMPLETED",
+        "CANCELLED",
+        "FAILED",
+      ]),
+      totalAmount: z.number(),
+      currencyCode: z.string().length(3),
+      billingDataSnapshot: z.any().nullable(),
+      createdAt: z.string().datetime(),
+      updatedAt: z.string().datetime(),
+      paidAt: z.string().datetime().nullable(),
+      completedAt: z.string().datetime().nullable(),
+      paymentDueDate: z.string().datetime(),
+    },
+    orderItems: {
+      orderItemId: z.string().uuid(),
+      orderId: z.string().uuid(),
+      productId: z.string().uuid(),
+      quantity: z.number(),
+      unitPrice: z.number(),
+      totalPrice: z.number(),
+      activityRegId: z.string().uuid().nullable(),
+      peMemberId: z.string().uuid().nullable(),
+      attributesSnapshot: z.any().nullable(),
+      status: z.enum([
+        "PENDING_PAYMENT",
+        "PAID",
+        "CANCELLED",
+        "FAILED",
+        "REFUNDED",
+      ]),
+    },
+    payments: {
+      paymentId: z.string().uuid(),
+      orderId: z.string().uuid(),
+      userId: z.string().uuid().nullable(),
+      amount: z.number(),
+      currencyCode: z.string().length(3),
+      paymentMethod: z.enum(["CARD", "SBP"]),
+      bank: z.enum(["YOOKASSA"]),
+      status: z.enum([
+        "PENDING",
+        "SUCCEEDED",
+        "FAILED",
+        "REFUNDED",
+        "CANCELED",
+      ]),
+      externalTransactionId: z.string().nullable(),
+      paymentGatewayDetails: z.any().nullable(),
+      createdAt: z.string().datetime(),
+      updatedAt: z.string().datetime(),
+      confirmation: z
+        .object({
+          type: z.enum([
+            "redirect",
+            "qr",
+            "embedded",
+            "external",
+            "mobile_application",
+          ]),
+          confirmationUrl: z.string().url().nullable(),
+        })
+        .nullable(),
+    },
+  },
 };
 
 export { DbSchema };

+ 9 - 0
src/db/db.ts

@@ -37,6 +37,15 @@ const typeParsers = [
       return value;
     },
   },
+  {
+    name: "timestamptz",
+    parse: (value: unknown) => {
+      if (typeof value === "string") {
+        return dayjs.utc(value).toISOString(); //TODO: жрет память
+      }
+      return value;
+    },
+  },
 ];
 
 const selConnectString = `postgres://${selUser}:${selPass}@${host}:${port}/${databaseName}`;

+ 13 - 0
src/main.ts

@@ -66,6 +66,14 @@ app.use("/api/client/pe/", cPeRouter);
 import cActRouter from "./modules/client/activities/c-act-router.js";
 app.use("/api/client/act/", cActRouter);
 
+// cart
+import cCartRouter from "./modules/client/shop/cart/c-cart-router.js";
+app.use("/api/client/cart/", cCartRouter);
+
+// orders
+import cOrdersRouter from "./modules/client/shop/c-orders-router.js";
+app.use("/api/client/orders/", cOrdersRouter);
+
 // events-management
 // import EventsRouter from "./modules/management/events-router.js";
 // app.use("/api/events-management/", authMiddleware(), EventsRouter);
@@ -75,6 +83,7 @@ app.use("/api/client/act/", cActRouter);
 
 // обработчик ошибок
 import errorMiddleware from "./middlewares/error-middleware.js";
+import { ordersService } from "#modules/client/shop/orders-service.js";
 
 app.use(errorMiddleware);
 //
@@ -88,6 +97,10 @@ const start = async () => {
 
     logger.info("Запуск сервера...");
     app.listen(PORT, () => logger.info(`🚀 Сервер запущен на порту ${PORT}`));
+
+    // Запуск проверки всех ожидающих заказов раз в час
+    ordersService.waitAllPendingOrders();
+    setInterval(() => ordersService.waitAllPendingOrders(), 60 * 60 * 1000);
   } catch (e) {
     logger.error(e);
   }

+ 29 - 2
src/middlewares/error-middleware.ts

@@ -4,6 +4,10 @@ import { ApiError } from "../exceptions/api-error.js";
 import { logger } from "../plugins/logger.js";
 import { ZodError } from "zod";
 import { UnexpectedError } from "#exceptions/unexpected-errors.js";
+import {
+  PaymentProviderError,
+  YooKassaApiError,
+} from "#modules/client/shop/payment/shop-errors.js";
 
 const globalErrorHandler: ErrorRequestHandler = (
   err: unknown, // Тип ошибки здесь может быть широким, далее идут проверки instanceof
@@ -52,8 +56,7 @@ const globalErrorHandler: ErrorRequestHandler = (
     });
     // TODO: Для клиента лучше отправлять обработанные ошибки, а не весь объект ZodError
     res.status(400).json({
-      message: "Ошибка валидации ZOD",
-      errors: err.flatten().fieldErrors,
+      message: "Внутренняя ошибка сервера (валидация)",
     });
     return;
   }
@@ -78,6 +81,30 @@ const globalErrorHandler: ErrorRequestHandler = (
     return;
   }
 
+  if (err instanceof YooKassaApiError) {
+    logger.error({
+      message: "Ошибка YooKassa API",
+      err: err,
+      request: requestDetails,
+    });
+    res
+      .status(500)
+      .json({ message: "Внутренняя ошибка сервера (YooKassa API)" });
+    return;
+  }
+
+  if (err instanceof PaymentProviderError) {
+    logger.error({
+      message: "Ошибка провайдера платежей",
+      err: err,
+      request: requestDetails,
+    });
+    res
+      .status(500)
+      .json({ message: "Внутренняя ошибка сервера (провайдер платежей)" });
+    return;
+  }
+
   // Непредвиденная ошибка
   logger.error({
     message: "Непредвиденная ошибка сервера",

+ 203 - 0
src/modules/client/shop/c-orders-controller.ts

@@ -0,0 +1,203 @@
+import sessionService from "#modules/users/auth/services/session-service.js";
+import { Request, Response } from "express";
+import { cartService } from "./cart/cart-service.js";
+import { ApiError } from "#exceptions/api-error.js";
+import { RouterUtils } from "#utils/router-utils.js";
+import { ordersService } from "./orders-service.js";
+import { api } from "#api/current-api.js";
+import { YooKassaProvider } from "./payment/yookassa-provider.js";
+import { PaymentService } from "./payment/payment-service.js";
+import { CreatePaymentParamsSchema } from "./payment/payment-provider-types.js";
+import { config } from "#config/config.js";
+import { UnexpectedError } from "#exceptions/unexpected-errors.js";
+import { updPool } from "#db/db.js";
+
+class ClientOrdersController {
+  async checkout(req: Request, res: Response) {
+    const user = sessionService.getUserFromReq(req, false);
+    const { userData, cartId } = api.client.shop.POST_Checkout.req.body.parse(
+      req.body,
+    );
+
+    // Общие проверки
+    if (!user && !cartId) {
+      throw ApiError.BadRequest(
+        "cartIdOrUserIdRequired",
+        "Не указан идентификатор корзины или пользователя",
+      );
+    }
+
+    // Получаем корзину в зависимости от типа пользователя
+    const cart = user
+      ? await cartService.getCart({ userId: user.userId }, false)
+      : await cartService.getCart({ cartId: cartId! }, false);
+
+    if (!cart) {
+      throw ApiError.BadRequest("cartNotFound", "Корзина не найдена");
+    }
+
+    // проверяем нет ли товаров из козины в другом заказе
+    const isExist = cart.items.find((i) => i.orderId);
+    if (isExist)
+      throw ApiError.BadRequest(
+        "cartItemsExistInOrder",
+        "Товары из корзины уже находятся в заказе",
+      );
+
+    const { orderId, payment } = await updPool.transaction(async (tr) => {
+      // Создаем заказ
+      const order = await ordersService.createOrder(tr, {
+        userId: user?.userId,
+        userData: userData,
+        cart: cart,
+      });
+
+      // создаём оплату
+
+      // Инициализация провайдера
+      const yookassaProvider = new YooKassaProvider();
+      // Инициализация сервиса с конкретным провайдером
+      const paymentService = new PaymentService(yookassaProvider);
+
+      const amount = {
+        value: order.totalAmount.toFixed(2),
+        currency: order.currencyCode,
+      };
+      // создание платежа
+      const paymentParams = CreatePaymentParamsSchema.parse({
+        bank: "YOOKASSA",
+        amount: amount,
+        description: `Заказ №${order.orderNumber}.`,
+        returnUrl: `${config.API_URL}/payment-success`,
+        metadata: {
+          order_number: order.orderNumber,
+          user_id: JSON.stringify(user?.userId),
+        }, // YooKassa требует string:string
+        capture: true,
+        // paymentMethodType: "bank_card" // Можно указать конкретный метод
+        orderId: order.orderId,
+        userId: user?.userId || null,
+      });
+
+      const payment = await paymentService.createPayment(tr, paymentParams);
+
+      if (payment.status !== "PENDING") {
+        throw new UnexpectedError(500, "paymentFailed", "Платеж не создан");
+      }
+
+      if (!payment.confirmation?.confirmationUrl) {
+        throw new UnexpectedError(500, "paymentFailed", "Платеж не создан");
+      }
+
+      return {
+        orderId: order.orderId,
+        payment,
+      };
+    });
+
+    const order = await ordersService.getOrder({ orderId });
+
+    if (!order) {
+      throw new Error("Созданный заказ не найден");
+    }
+
+    // асинхронное ожидание оплаты
+    ordersService.waitPandingOrder(orderId);
+
+    RouterUtils.validAndSendResponse(api.client.shop.POST_Checkout.res, res, {
+      code: "success",
+      order: order,
+      payment: {
+        paymentId: payment.paymentId,
+        confirmation: payment.confirmation,
+        status: payment.status,
+      },
+    });
+  }
+
+  async getOrder(req: Request, res: Response) {
+    const { orderNumber } = api.client.shop.GET_Order.req.params.parse(
+      req.params,
+    );
+
+    const user = sessionService.getUserFromReq(req, false);
+
+    const order = await ordersService.getOrder({ orderNumber });
+
+    if (!order) {
+      throw ApiError.BadRequest("orderNotFound", "Заказ не найден");
+    }
+
+    if (order.userId) {
+      if (order.userId !== user?.userId) {
+        throw ApiError.ForbiddenError();
+      }
+    }
+
+    const payment = await ordersService.getLastPaymentByOrderId(order.orderId);
+
+    if (!payment) {
+      throw ApiError.BadRequest("paymentNotFound", "Платеж не найден");
+    }
+
+    RouterUtils.validAndSendResponse(api.client.shop.GET_Order.res, res, {
+      code: "success",
+      order: order,
+      payment: {
+        paymentId: payment.paymentId,
+        confirmation: payment.confirmation,
+        status: payment.status,
+      },
+    });
+  }
+
+  async getOrders(req: Request, res: Response) {
+    const user = sessionService.getUserFromReq(req);
+
+    const orders = await ordersService.getOrders(user.userId);
+
+    RouterUtils.validAndSendResponse(api.client.shop.GET_Orders.res, res, {
+      code: "success",
+      orders: [...orders],
+    });
+  }
+
+  async cancelOrder(req: Request, res: Response) {
+    const { orderId } = api.client.shop.POST_CancelOrder.req.params.parse(
+      req.params,
+    );
+
+    await ordersService.cancelOrder(orderId);
+
+    RouterUtils.validAndSendResponse(
+      api.client.shop.POST_CancelOrder.res,
+      res,
+      {
+        code: "success",
+      },
+    );
+  }
+
+  async getPayment(req: Request, res: Response) {
+    const { orderId } = api.client.shop.GET_Payment.req.params.parse(
+      req.params,
+    );
+
+    const payment = await ordersService.getLastPaymentByOrderId(orderId);
+
+    if (!payment) {
+      throw ApiError.BadRequest("paymentNotFound", "Платеж не найден");
+    }
+
+    RouterUtils.validAndSendResponse(api.client.shop.GET_Payment.res, res, {
+      code: "success",
+      payment: {
+        paymentId: payment.paymentId,
+        confirmation: payment.confirmation,
+        status: payment.status,
+      },
+    });
+  }
+}
+
+export const clientOrdersController = new ClientOrdersController();

+ 24 - 0
src/modules/client/shop/c-orders-router.ts

@@ -0,0 +1,24 @@
+import { RouterUtils } from "#utils/router-utils.js";
+
+import express from "express";
+const router = express.Router();
+export default router;
+
+import { clientOrdersController } from "./c-orders-controller.js";
+router.get("/", RouterUtils.asyncHandler(clientOrdersController.getOrders));
+router.get(
+  "/:orderNumber",
+  RouterUtils.asyncHandler(clientOrdersController.getOrder),
+);
+router.post(
+  "/checkout",
+  RouterUtils.asyncHandler(clientOrdersController.checkout),
+);
+router.delete(
+  "/:orderId",
+  RouterUtils.asyncHandler(clientOrdersController.cancelOrder),
+);
+router.get(
+  "/:orderId/payment",
+  RouterUtils.asyncHandler(clientOrdersController.getPayment),
+);

+ 145 - 0
src/modules/client/shop/cart/c-cart-controller.ts

@@ -0,0 +1,145 @@
+import { api } from "#api/current-api.js";
+import { Request, Response } from "express";
+import { cartService } from "./cart-service.js";
+import sessionService from "#modules/users/auth/services/session-service.js";
+import { ApiError } from "#exceptions/api-error.js";
+import { RouterUtils } from "#utils/router-utils.js";
+
+class ClientCartController {
+  async addItemToCart(req: Request, res: Response) {
+    const product = api.client.shop.POST_CartItem.req.body.parse(req.body);
+    const { cartId } = api.client.shop.POST_CartItem.req.params.parse(
+      req.params,
+    );
+
+    // TODO: Обернуть все updPool в транзакцию
+    const user = sessionService.getUserFromReq(req, false);
+
+    if (!cartId && !user) {
+      throw ApiError.BadRequest(
+        "cartIdOrUserIdRequired",
+        "Не указан идентификатор корзины или пользователя",
+      );
+    }
+
+    // проверка на существование товара
+    const realProduct = await cartService.getProductById(product.productId);
+
+    if (!realProduct) {
+      throw ApiError.BadRequest("productNotFound", "Товар не найден");
+    }
+
+    // проверка на наличие корзины пользователя
+
+    const cart = await cartService.getCart(
+      user ? { userId: user.userId } : { cartId: cartId! },
+      true,
+    );
+
+    if (cart) {
+      //  проверка на наличие товара в корзине
+      const cartItem = cart.items.find(
+        (item) =>
+          item.productId === product.productId &&
+          item.activityRegId ===
+            ("activityRegId" in product ? product.activityRegId : null) &&
+          item.peMemberId ===
+            ("peMemberId" in product ? product.peMemberId : null),
+      );
+
+      // в корзине уже есть такой товар
+      if (cartItem) {
+        throw ApiError.BadRequest("itemAlreadyInCart", "Товар уже в корзине");
+      }
+      // в корзине нет такого товара
+      else {
+        await cartService.addItemToCart({
+          cartId: cart.cartId,
+          productId: realProduct.productId,
+          priceAtAddition: realProduct.price,
+          activityRegId:
+            "activityRegId" in product ? product.activityRegId : null,
+          peMemberId: "peMemberId" in product ? product.peMemberId : null,
+        });
+      }
+
+      RouterUtils.validAndSendResponse(api.client.shop.POST_CartItem.res, res, {
+        code: "success",
+      });
+      return;
+    }
+    // корзины нет
+    else {
+      const cartId = await cartService.createCart(user?.userId);
+      await cartService.addItemToCart({
+        cartId: cartId,
+        productId: realProduct.productId,
+        priceAtAddition: realProduct.price,
+        activityRegId:
+          "activityRegId" in product ? product.activityRegId : null,
+        peMemberId: "peMemberId" in product ? product.peMemberId : null,
+      });
+
+      RouterUtils.validAndSendResponse(api.client.shop.POST_CartItem.res, res, {
+        code: "success",
+        cartId,
+      });
+      return;
+    }
+  }
+
+  async deleteCartItem(req: Request, res: Response) {
+    const { cartItemId } = api.client.shop.DELETE_CartItem.req.params.parse(
+      req.params,
+    );
+
+    await cartService.deleteCartItem(cartItemId);
+
+    RouterUtils.validAndSendResponse(api.client.shop.DELETE_CartItem.res, res, {
+      code: "success",
+    });
+  }
+
+  async updateCartItemQuantity(req: Request, res: Response) {
+    const { cartItemId } =
+      api.client.shop.PUT_CartItemQuantity.req.params.parse(req.params);
+    const { quantity } = api.client.shop.PUT_CartItemQuantity.req.body.parse(
+      req.body,
+    );
+
+    await cartService.updateCartItemQuantity(cartItemId, quantity);
+
+    RouterUtils.validAndSendResponse(
+      api.client.shop.PUT_CartItemQuantity.res,
+      res,
+      {
+        code: "success",
+      },
+    );
+  }
+
+  async getCart(req: Request, res: Response) {
+    const { cartId } = api.client.shop.GET_Cart.req.params.parse(req.params);
+
+    const user = sessionService.getUserFromReq(req, false);
+
+    if (!cartId && !user) {
+      throw ApiError.BadRequest(
+        "cartIdOrUserIdRequired",
+        "Не указан идентификатор корзины или пользователя",
+      );
+    }
+
+    const cart = await cartService.getFullCart(
+      user ? { userId: user.userId } : { cartId: cartId! },
+      false,
+    );
+
+    RouterUtils.validAndSendResponse(api.client.shop.GET_Cart.res, res, {
+      code: "success",
+      cart,
+    });
+  }
+}
+
+export const clientCartController = new ClientCartController();

+ 22 - 0
src/modules/client/shop/cart/c-cart-router.ts

@@ -0,0 +1,22 @@
+import { RouterUtils } from "#utils/router-utils.js";
+
+import express from "express";
+const router = express.Router();
+export default router;
+
+import { clientCartController } from "./c-cart-controller.js";
+
+router.get("/", RouterUtils.asyncHandler(clientCartController.getCart));
+router.get("/:cartId", RouterUtils.asyncHandler(clientCartController.getCart));
+
+router.post("/", RouterUtils.asyncHandler(clientCartController.addItemToCart));
+
+router.delete(
+  "/:cartItemId",
+  RouterUtils.asyncHandler(clientCartController.deleteCartItem),
+);
+
+router.put(
+  "/:cartItemId/quantity",
+  RouterUtils.asyncHandler(clientCartController.updateCartItemQuantity),
+);

+ 251 - 0
src/modules/client/shop/cart/cart-service.ts

@@ -0,0 +1,251 @@
+import { CustomFieldWithValue } from "#api/v_0.1.0/types/custom-fields-types.js";
+import { DbSchema } from "#db/db-schema.js";
+import { selPool, updPool } from "#db/db.js";
+import { sql } from "slonik";
+import { z } from "zod";
+import { Cart } from "./cart-types.js";
+
+class CartService {
+  async getCart(
+    entity: { userId: string } | { cartId: string },
+    withItemsInOrder: boolean,
+  ): Promise<Cart | null> {
+    const cart = await selPool.maybeOne(sql.type(
+      z.object({
+        cartId: DbSchema.shop.carts.cartId,
+        createdAt: DbSchema.shop.carts.createdAt,
+        updatedAt: DbSchema.shop.carts.updatedAt,
+        items: z.array(
+          z.object({
+            cartItemId: DbSchema.shop.cartItems.cartItemId,
+            cartId: DbSchema.shop.cartItems.cartId,
+            productId: DbSchema.shop.cartItems.productId,
+            quantity: DbSchema.shop.cartItems.quantity,
+            priceAtAddition: DbSchema.shop.cartItems.priceAtAddition,
+            activityRegId: DbSchema.shop.cartItems.activityRegId,
+            peMemberId: DbSchema.shop.cartItems.peMemberId,
+            name: DbSchema.shop.products.name,
+            productType: DbSchema.shop.products.productType,
+            orderId: DbSchema.shop.cartItems.orderId,
+          }),
+        ),
+      }),
+    )`
+        select
+          c.cart_id "cartId",
+          c.created_at "createdAt",
+          c.updated_at "updatedAt",
+          coalesce(i.items, '[]'::jsonb) "items"
+        from
+          shop.carts c
+        left join lateral (
+          select
+            jsonb_agg(jsonb_build_object(
+              'cartItemId', ci.cart_item_id,
+              'cartId', ci.cart_id,
+              'productId', ci.product_id,
+              'quantity', ci.quantity,
+              'priceAtAddition', ci.price_at_addition,
+              'activityRegId', ci.activity_reg_id,
+              'peMemberId', ci.pe_member_id,
+              'name', p."name",
+			        'productType', p.product_type,
+              'orderId', ci.order_id
+            )) items
+          from
+            shop.cart_items ci
+          left join shop.products p on
+            p.product_id = ci.product_id
+          where
+            ci.cart_id = c.cart_id
+            ${withItemsInOrder ? sql.fragment`and ci.order_id is null` : sql.fragment``}
+        ) i on
+          true
+        where
+            ${"cartId" in entity ? sql.fragment`c.cart_id = ${entity.cartId}` : sql.fragment`c.user_id = ${entity.userId}`}
+        `);
+    return cart;
+  }
+
+  async getFullCart(
+    entity: { userId: string } | { cartId: string },
+    withItemsInOrder: boolean,
+  ) {
+    const cart = await selPool.maybeOne(sql.type(
+      z.object({
+        cartId: DbSchema.shop.carts.cartId,
+        createdAt: DbSchema.shop.carts.createdAt,
+        updatedAt: DbSchema.shop.carts.updatedAt,
+        items: z.array(
+          z.object({
+            cartItemId: DbSchema.shop.cartItems.cartItemId,
+            productId: DbSchema.shop.cartItems.productId,
+            productName: DbSchema.shop.products.name,
+            quantity: DbSchema.shop.cartItems.quantity,
+            productType: DbSchema.shop.products.productType,
+            priceAtAddition: DbSchema.shop.cartItems.priceAtAddition,
+            realPrice: DbSchema.shop.products.price,
+            currencyCode: DbSchema.shop.products.currencyCode,
+            isActive: DbSchema.shop.products.isActive,
+            attributes: DbSchema.shop.products.attributes,
+
+            activityRegId: DbSchema.shop.cartItems.activityRegId.nullable(),
+            activityPublicName: DbSchema.act.activities.publicName.nullable(),
+            activityRegNumber: DbSchema.act.activityRegs.number.nullable(),
+
+            peMemberId: DbSchema.shop.cartItems.peMemberId.nullable(),
+            peMemberFields: CustomFieldWithValue.extend({
+              userEfId: z.string().uuid(),
+            }).nullable(),
+
+            name: DbSchema.shop.products.name,
+            addedAt: DbSchema.shop.cartItems.addedAt,
+          }),
+        ),
+      }),
+    )`
+        select
+          c.cart_id "cartId",
+          c.created_at "createdAt",
+          c.updated_at "updatedAt",
+          coalesce(i.items, '[]'::jsonb) "items"
+        from
+          shop.carts c
+        left join lateral (
+          select
+            jsonb_agg(jsonb_build_object(
+              'cartItemId', ci.cart_item_id,
+              'productId', ci.product_id,
+              'productName', p.name,
+              'quantity', ci.quantity,
+			        'productType', p.product_type,
+              'priceAtAddition', ci.price_at_addition,
+              'realPrice', p.price,
+              'currencyCode', p.currency_code,
+              'isActive', p.is_active,
+              'attributes', p.attributes,
+
+              'activityRegId', ci.activity_reg_id,
+              'activityPublicName', a.public_name,
+              'activityRegNumber', ar.number,
+
+              'peMemberId', ci.pe_member_id,
+              'peMemberFields', pm.fields,
+
+              'name', p."name",
+              'addedAt', to_char(ci.added_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS') || 'Z'
+            )) items
+          from
+            shop.cart_items ci
+          left join shop.products p on
+            p.product_id = ci.product_id
+          left join act.activity_regs ar on
+            ar.activity_reg_id = ci.activity_reg_id
+          left join act.activities a on
+            a.activity_id = ar.activity_id
+          left join act.pe_members_with_fields_and_values pm on
+            pm.pe_member_id = ci.pe_member_id
+          where
+            ci.cart_id = c.cart_id
+            ${withItemsInOrder ? sql.fragment`and ci.order_id is null` : sql.fragment``}
+        ) i on
+          true
+        where
+            ${"cartId" in entity ? sql.fragment`c.cart_id = ${entity.cartId}` : sql.fragment`c.user_id = ${entity.userId}`}
+        `);
+    return cart;
+  }
+
+  async createCart(userId?: string) {
+    const cartId = await updPool.one(sql.unsafe`
+            insert into shop.carts (user_id)
+            values (${userId || null})
+            returning cart_id
+        `);
+    return cartId;
+  }
+
+  // async cartItemQuantityIncrement(cartItemId: string) {
+  //   await updPool.query(sql.unsafe`
+  //           update shop.cart_items
+  //           set quantity = quantity + 1
+  //           where cart_item_id = ${cartItemId}
+  //       `);
+  // }
+
+  async addItemToCart(item: {
+    cartId: string;
+    productId: string;
+    priceAtAddition: number;
+    activityRegId: string | null;
+    peMemberId: string | null;
+  }) {
+    await updPool.query(sql.unsafe`
+            insert into shop.cart_items (cart_id, product_id, quantity, price_at_addition, activity_reg_id, pe_member_id)
+            values (${item.cartId}, ${item.productId}, 1, ${item.priceAtAddition}, ${item.activityRegId}, ${item.peMemberId})
+        `);
+  }
+
+  async deleteCartItem(cartItemId: string) {
+    await updPool.query(sql.unsafe`
+            delete from shop.cart_items
+            where cart_item_id = ${cartItemId}
+        `);
+  }
+
+  async updateCartItemQuantity(cartItemId: string, quantity: number) {
+    await updPool.query(sql.unsafe`
+            update shop.cart_items
+            set quantity = ${quantity}
+            where cart_item_id = ${cartItemId}
+        `);
+  }
+
+  async clearCart(cartId: string) {
+    await updPool.query(sql.unsafe`
+            delete from shop.cart_items
+            where cart_id = ${cartId}
+        `);
+  }
+
+  async getProductById(productId: string) {
+    return selPool.maybeOne(sql.type(
+      z.object({
+        productId: DbSchema.shop.products.productId,
+        categoryId: DbSchema.shop.products.categoryId,
+        productType: DbSchema.shop.products.productType,
+        sku: DbSchema.shop.products.sku,
+        name: DbSchema.shop.products.name,
+        description: DbSchema.shop.products.description,
+        price: DbSchema.shop.products.price,
+        currencyCode: DbSchema.shop.products.currencyCode,
+        stockQuantity: DbSchema.shop.products.stockQuantity,
+        isActive: DbSchema.shop.products.isActive,
+        attributes: DbSchema.shop.products.attributes,
+        createdAt: DbSchema.shop.products.createdAt,
+        updatedAt: DbSchema.shop.products.updatedAt,
+      }),
+    )`
+      select
+        p.product_id "productId",
+        p.category_id "categoryId",
+        p.product_type "productType",
+        p.sku,
+        p.name,
+        p.description,
+        p.price::float "price",
+        p.currency_code "currencyCode",
+        p.stock_quantity "stockQuantity",
+        p.is_active "isActive",
+        p.attributes,
+        p.created_at "createdAt",
+        p.updated_at "updatedAt"
+      from
+        shop.products p
+      where
+        p.product_id = ${productId}
+    `);
+  }
+}
+
+export const cartService = new CartService();

+ 21 - 0
src/modules/client/shop/cart/cart-types.ts

@@ -0,0 +1,21 @@
+export type Cart = {
+  cartId: string;
+  createdAt: string;
+  updatedAt: string;
+  items: {
+    cartId: string;
+    cartItemId: string;
+    productId: string;
+    quantity: number;
+    priceAtAddition: number;
+    activityRegId: string | null;
+    peMemberId: string | null;
+    name: string;
+    productType:
+      | "SHOP_ORDER"
+      | "TICKET"
+      | "ACTIVITY_REGISTRATION"
+      | "ACTIVITY_PARTICIPANT";
+    orderId: string | null;
+  }[];
+};

+ 402 - 0
src/modules/client/shop/orders-service.ts

@@ -0,0 +1,402 @@
+import { selPool, updPool } from "#db/db.js";
+import { DatabaseTransactionConnection, SerializableValue, sql } from "slonik";
+import { v7 as uuidv7 } from "uuid";
+import { cartService } from "./cart/cart-service.js";
+import { ApiError } from "#exceptions/api-error.js";
+import { z } from "zod";
+import { DbSchema } from "#db/db-schema.js";
+import { CustomFieldWithValue } from "#api/v_0.1.0/types/custom-fields-types.js";
+import { generateRandomNumber } from "#utils/other-utils.js";
+import { Cart } from "./cart/cart-types.js";
+import { dayjs } from "#plugins/dayjs.js";
+import { PaymentService } from "./payment/payment-service.js";
+import { logger } from "#plugins/logger.js";
+import { YooKassaProvider } from "./payment/yookassa-provider.js";
+import { cActService } from "../activities/c-act-service.js";
+
+type OrderUserData = Record<string, SerializableValue> & {
+  firstName: string;
+  lastName: string;
+  patronymic: string | null;
+  email: string;
+  phone: string;
+};
+
+class OrdersService {
+  async createOrder(
+    tr: DatabaseTransactionConnection,
+    {
+      cart,
+      userId,
+      userData,
+    }: {
+      cart: Cart;
+      userId?: string;
+      userData: OrderUserData;
+    },
+  ) {
+    const products = await Promise.all(
+      cart.items.map(async (item) => {
+        const p = await cartService.getProductById(item.productId);
+        if (!p) {
+          throw ApiError.BadRequest(
+            "productNotFound",
+            "Не найден товар из корзины",
+          );
+        }
+        if (item.priceAtAddition !== p.price) {
+          throw ApiError.BadRequest(
+            "productPriceChanged",
+            "Цена товара изменилась",
+          );
+        }
+        return {
+          ...p,
+          quantity: item.quantity,
+          activityRegId: item.activityRegId,
+          peMemberId: item.peMemberId,
+          cartItemId: item.cartItemId,
+        };
+      }),
+    );
+
+    // order
+    const orderId = uuidv7();
+
+    let orderNumber = generateRandomNumber();
+
+    while (await this.checkOrderNumber(orderNumber)) {
+      orderNumber = generateRandomNumber();
+    }
+
+    const totalAmount = products.reduce((acc, product) => {
+      return acc + product.price * product.quantity;
+    }, 0);
+
+    const paymentDueDate = dayjs().add(1, "hour").toDate();
+
+    await tr.query(sql.unsafe`
+      insert into shop.orders (
+        order_id, 
+        order_number, 
+        user_id, 
+        status, 
+        total_amount, 
+        currency_code, 
+        billing_data_snapshot,
+        payment_due_date
+        )
+      values (
+        ${orderId}, 
+        ${orderNumber}, 
+        ${userId || null}, 
+        'PENDING_PAYMENT', 
+        ${totalAmount}, 
+        'RUB', 
+        ${sql.jsonb(userData)},
+        ${paymentDueDate.toISOString()}
+      )
+    `);
+
+    for (const product of products) {
+      await tr.query(sql.unsafe`
+      insert into shop.order_items (
+        order_item_id, 
+        order_id, 
+        product_id, 
+        quantity, 
+        unit_price, 
+        total_price,
+        activity_reg_id,
+        pe_member_id,
+        attributes_snapshot
+        )
+      values (
+        ${uuidv7()}, 
+        ${orderId}, 
+        ${product.productId}, 
+        ${product.quantity}, 
+        ${product.price}, 
+        ${product.price * product.quantity},
+        ${"activityRegId" in product ? product.activityRegId : null},
+        ${"peMemberId" in product ? product.peMemberId : null},
+        ${sql.jsonb(product.attributes)}
+      )
+    `);
+
+      // чтобы скрыть из корзины
+      await tr.query(sql.unsafe`
+          update shop.cart_items set order_id = ${orderId} where cart_item_id = ${product.cartItemId}
+        `);
+    }
+
+    return {
+      orderId,
+      orderNumber,
+      totalAmount,
+      currencyCode: "RUB",
+      billingDataSnapshot: userData,
+      paymentDueDate,
+      items: products,
+    };
+  }
+
+  private async checkOrderNumber(orderNumber: string) {
+    const order = await selPool.exists(sql.unsafe`
+      select 1 from shop.orders where order_number = ${orderNumber}
+    `);
+
+    return !!order;
+  }
+
+  async getOrder(order: { orderId: string } | { orderNumber: string }) {
+    return await selPool.maybeOne(sql.type(
+      z.object({
+        orderId: DbSchema.shop.orders.orderId,
+        orderNumber: DbSchema.shop.orders.orderNumber,
+        userId: DbSchema.shop.orders.userId,
+        status: DbSchema.shop.orders.status,
+        totalAmount: DbSchema.shop.orders.totalAmount,
+        currencyCode: DbSchema.shop.orders.currencyCode,
+        billingDataSnapshot: DbSchema.shop.orders.billingDataSnapshot,
+        createdAt: DbSchema.shop.orders.createdAt,
+        paymentDueDate: DbSchema.shop.orders.paymentDueDate,
+        items: z.array(
+          z.object({
+            orderItemId: DbSchema.shop.orderItems.orderItemId,
+            orderId: DbSchema.shop.orderItems.orderId,
+            productId: DbSchema.shop.orderItems.productId,
+            quantity: DbSchema.shop.orderItems.quantity,
+            unitPrice: DbSchema.shop.orderItems.unitPrice,
+            totalPrice: DbSchema.shop.orderItems.totalPrice,
+            activityRegId: DbSchema.shop.orderItems.activityRegId.nullable(),
+            peMemberId: DbSchema.shop.orderItems.peMemberId.nullable(),
+            attributesSnapshot: DbSchema.shop.orderItems.attributesSnapshot,
+            productName: DbSchema.shop.products.name,
+            productType: DbSchema.shop.products.productType,
+            stockQuantity: DbSchema.shop.products.stockQuantity,
+            actPublicName: DbSchema.act.activities.publicName.nullable(),
+            peMemberFields: CustomFieldWithValue.extend({
+              userEfId: z.string().uuid(),
+            }).nullable(),
+          }),
+        ),
+      }),
+    )`
+    select 
+        order_id as "orderId",
+        order_number as "orderNumber",
+        user_id as "userId",
+        status,
+        total_amount::float as "totalAmount",
+        currency_code as "currencyCode",
+        billing_data_snapshot as "billingDataSnapshot",
+        created_at as "createdAt",
+        payment_due_date as "paymentDueDate",
+        items
+    from 
+      shop.orders_with_items
+    where
+        ${"orderId" in order ? sql.fragment`order_id = ${order.orderId}` : sql.fragment`order_number = ${order.orderNumber}`}
+    `);
+  }
+
+  async cancelOrder(orderId: string) {
+    await updPool.query(sql.unsafe`
+      update shop.orders set status = 'CANCELLED' where order_id = ${orderId}
+    `);
+  }
+
+  async getLastPaymentByOrderId(orderId: string) {
+    return await selPool.maybeOne(sql.type(
+      z.object({
+        paymentId: DbSchema.shop.payments.paymentId,
+        orderId: DbSchema.shop.payments.orderId,
+        userId: DbSchema.shop.payments.userId,
+        amount: DbSchema.shop.payments.amount,
+        currencyCode: DbSchema.shop.payments.currencyCode,
+        paymentMethod: DbSchema.shop.payments.paymentMethod,
+        bank: DbSchema.shop.payments.bank,
+        status: DbSchema.shop.payments.status,
+        externalTransactionId: DbSchema.shop.payments.externalTransactionId,
+        paymentGatewayDetails: DbSchema.shop.payments.paymentGatewayDetails,
+        createdAt: DbSchema.shop.payments.createdAt,
+        updatedAt: DbSchema.shop.payments.updatedAt,
+        confirmation: DbSchema.shop.payments.confirmation,
+      }),
+    )`
+      select 
+        payment_id as "paymentId",
+        order_id as "orderId",
+        user_id as "userId",
+        amount::float as "amount",
+        currency_code as "currencyCode",
+        payment_method as "paymentMethod",
+        bank,
+        status,
+        external_transaction_id "externalTransactionId",
+        payment_gateway_details "paymentGatewayDetails",
+        created_at "createdAt",
+        updated_at "updatedAt",
+        confirmation
+      from 
+        shop.payments
+      where
+        order_id = ${orderId}
+      order by 
+        created_at desc
+      limit 1;
+    `);
+  }
+
+  async waitPandingOrder(orderId: string) {
+    const order = await this.getOrder({ orderId });
+    const payment = await this.getLastPaymentByOrderId(orderId);
+
+    if (!order || !payment) {
+      logger.error("Order or payment not found");
+      return;
+    }
+
+    // Инициализация провайдера
+    const yookassaProvider = new YooKassaProvider();
+    // Инициализация сервиса с конкретным провайдером
+    const paymentService = new PaymentService(yookassaProvider);
+
+    const status = await paymentService.waitPayment(
+      payment.paymentId,
+      order.paymentDueDate,
+    );
+    if (status === "succeeded") {
+      await updPool.query(sql.unsafe`
+          update shop.orders
+          set status = 'PAID'
+          where order_id = ${orderId}
+        `);
+
+      await updPool.query(sql.unsafe`
+          update shop.order_items
+          set status = 'PAID'
+          where order_id = ${orderId}
+        `);
+
+      // статус для регистраций
+      for (const item of order.items) {
+        if (
+          item.productType === "ACTIVITY_REGISTRATION" ||
+          item.productType === "ACTIVITY_PARTICIPANT"
+        ) {
+          if (!item.activityRegId) throw new Error("activityRegId not found");
+          await cActService.updateActRegPaymentStatus(item.activityRegId);
+        }
+      }
+
+      // удаляем из корзины
+      await updPool.query(sql.unsafe`
+          delete from shop.cart_items where order_id = ${orderId}
+        `);
+    }
+
+    if (status === "canceled") {
+      await updPool.query(sql.unsafe`
+          update shop.orders
+          set status = 'CANCELLED'
+          where order_id = ${orderId}
+        `);
+
+      await updPool.query(sql.unsafe`
+          update shop.order_items
+          set status = 'CANCELLED'
+          where order_id = ${orderId}
+        `);
+
+      // возвращаем в корзину
+      await updPool.query(sql.unsafe`
+          update shop.cart_items set order_id = null where order_id = ${orderId}
+        `);
+    }
+
+    if (status === "failed") {
+      await updPool.query(sql.unsafe`
+          update shop.orders
+          set status = 'FAILED'
+          where order_id = ${orderId}
+        `);
+
+      // возвращаем в корзину
+      await updPool.query(sql.unsafe`
+          update shop.cart_items set order_id = null where order_id = ${orderId}
+        `);
+    }
+  }
+
+  async waitAllPendingOrders() {
+    logger.info("Запуск проверки всех ожидающих заказов...");
+    const orders = await this.getAllPendingOrders();
+    for (const orderId of orders) {
+      await this.waitPandingOrder(orderId);
+    }
+  }
+
+  async getAllPendingOrders() {
+    return await selPool.anyFirst(sql.type(
+      z.object({
+        orderId: z.string().uuid(),
+      }),
+    )`
+      select order_id "orderId" from shop.orders where status = 'PENDING_PAYMENT'
+    `);
+  }
+
+  async getOrders(userId: string) {
+    return selPool.any(sql.type(
+      z.object({
+        orderId: DbSchema.shop.orders.orderId,
+        orderNumber: DbSchema.shop.orders.orderNumber,
+        userId: DbSchema.shop.orders.userId,
+        status: DbSchema.shop.orders.status,
+        totalAmount: DbSchema.shop.orders.totalAmount,
+        currencyCode: DbSchema.shop.orders.currencyCode,
+        billingDataSnapshot: DbSchema.shop.orders.billingDataSnapshot,
+        createdAt: DbSchema.shop.orders.createdAt,
+        paymentDueDate: DbSchema.shop.orders.paymentDueDate,
+        items: z.array(
+          z.object({
+            orderItemId: DbSchema.shop.orderItems.orderItemId,
+            orderId: DbSchema.shop.orderItems.orderId,
+            productId: DbSchema.shop.orderItems.productId,
+            quantity: DbSchema.shop.orderItems.quantity,
+            unitPrice: DbSchema.shop.orderItems.unitPrice,
+            totalPrice: DbSchema.shop.orderItems.totalPrice,
+            activityRegId: DbSchema.shop.orderItems.activityRegId.nullable(),
+            peMemberId: DbSchema.shop.orderItems.peMemberId.nullable(),
+            attributesSnapshot: DbSchema.shop.orderItems.attributesSnapshot,
+            productName: DbSchema.shop.products.name,
+            productType: DbSchema.shop.products.productType,
+            stockQuantity: DbSchema.shop.products.stockQuantity,
+            actPublicName: DbSchema.act.activities.publicName.nullable(),
+            peMemberFields: CustomFieldWithValue.extend({
+              userEfId: z.string().uuid(),
+            }).nullable(),
+          }),
+        ),
+      }),
+    )`
+      select 
+        order_id "orderId",
+        order_number "orderNumber",
+        user_id "userId",
+        status,
+        total_amount::float "totalAmount",
+        currency_code "currencyCode",
+        billing_data_snapshot "billingDataSnapshot",
+        created_at "createdAt",
+        payment_due_date "paymentDueDate",
+        items
+      from shop.orders_with_items
+      where user_id = ${userId}
+    `);
+  }
+}
+
+export const ordersService = new OrdersService();

+ 137 - 0
src/modules/client/shop/payment/payment-provider-types.ts

@@ -0,0 +1,137 @@
+import { z } from "zod";
+
+// --- Общие параметры для создания платежа ---
+export const CreatePaymentParamsSchema = z.object({
+  bank: z.enum(["YOOKASSA"]),
+  amount: z.object({
+    value: z
+      .string()
+      .regex(
+        /^\d+\.\d{2}$/,
+        "Amount value must be a string with 2 decimal places (e.g., '10.00')",
+      ),
+    currency: z.string().length(3).toUpperCase(), // e.g., "RUB"
+  }),
+  orderId: z.string(), // ID заказа в вашей системе
+  userId: z.string().nullable(), // ID пользователя в вашей системе
+  description: z.string().max(128).optional(), // Описание заказа
+  returnUrl: z.string().url(), // URL для редиректа пользователя после оплаты
+  metadata: z.record(z.any()).optional(), // Дополнительные данные
+  capture: z.boolean().default(true), // Автоматическое списание или холдирование
+  paymentMethodType: z.enum(["CARD"]).optional(), // e.g., 'bank_card', 'sbp'. Если не указан, YooKassa покажет все доступные.
+});
+export type CreatePaymentParams = z.infer<typeof CreatePaymentParamsSchema>;
+
+// --- Общий ответ при создании платежа ---
+export const PaymentResponseSchema = z.object({
+  id: z.string(), // ID платежа в системе провайдера
+  status: z.enum(["pending", "waiting_for_capture", "succeeded", "canceled"]), // Статус платежа (e.g., 'pending', 'succeeded')
+  paid: z.boolean(),
+  amount: z.object({
+    value: z.string(),
+    currency: z.string(),
+  }),
+  confirmation: z
+    .object({
+      type: z.enum([
+        "redirect",
+        "qr",
+        "embedded",
+        "external",
+        "mobile_application",
+      ]),
+      confirmationUrl: z.string().url().optional(), // URL для подтверждения (если нужен редирект)
+    })
+    .optional(),
+  createdAt: z.string().datetime(),
+  description: z.string().optional().nullable(),
+  metadata: z.record(z.any()).optional().nullable(),
+  providerSpecificDetails: z.any().optional(), // Детали, специфичные для провайдера
+});
+export type PaymentResponse = z.infer<typeof PaymentResponseSchema>;
+
+// --- Общие параметры для получения статуса платежа ---
+export const PaymentStatusResponseSchema = PaymentResponseSchema.omit({
+  confirmation: true,
+}).extend({
+  test: z.boolean().optional(), // Для YooKassa
+  incomeAmount: z
+    .object({
+      // Для YooKassa, сумма за вычетом комиссии
+      value: z.string(),
+      currency: z.string(),
+    })
+    .optional(),
+  refundedAmount: z
+    .object({
+      // Для YooKassa
+      value: z.string(),
+      currency: z.string(),
+    })
+    .optional(),
+});
+export type PaymentStatusResponse = z.infer<typeof PaymentStatusResponseSchema>;
+
+// --- Общие параметры для создания возврата ---
+export const CreateRefundParamsSchema = z.object({
+  paymentId: z.string(), // ID оригинального платежа
+  amount: z.object({
+    value: z
+      .string()
+      .regex(
+        /^\d+\.\d{2}$/,
+        "Amount value must be a string with 2 decimal places (e.g., '10.00')",
+      ),
+    currency: z.string().length(3).toUpperCase(),
+  }),
+  description: z.string().max(250).optional(), // Причина возврата
+  metadata: z.record(z.any()).optional(),
+});
+export type CreateRefundParams = z.infer<typeof CreateRefundParamsSchema>;
+
+// --- Общий ответ при создании возврата ---
+export const RefundResponseSchema = z.object({
+  id: z.string(), // ID возврата
+  paymentId: z.string(),
+  status: z.string(), // Статус возврата (e.g., 'pending', 'succeeded')
+  amount: z.object({
+    value: z.string(),
+    currency: z.string(),
+  }),
+  createdAt: z.string().datetime(),
+  description: z.string().optional().nullable(),
+  providerSpecificDetails: z.any().optional(),
+});
+export type RefundResponse = z.infer<typeof RefundResponseSchema>;
+
+// --- Общие параметры для отмены платежа ---
+export const CancelPaymentParamsSchema = z.object({
+  paymentId: z.string(), // ID платежа, который нужно отменить
+});
+export type CancelPaymentParams = z.infer<typeof CancelPaymentParamsSchema>;
+
+// --- Общий ответ при отмене платежа ---
+// Ответ при отмене платежа по структуре идентичен ответу о статусе платежа,
+// так как отмена - это фактически изменение статуса существующего платежа на "canceled".
+export const CancelPaymentResponseSchema = PaymentStatusResponseSchema.describe(
+  "Response after a payment cancellation attempt, representing the updated payment status.",
+);
+export type CancelPaymentResponse = z.infer<typeof CancelPaymentResponseSchema>;
+
+// --- Интерфейс платежного провайдера ---
+export interface IPaymentProvider {
+  createPayment(
+    params: CreatePaymentParams,
+    idempotencyKey?: string,
+  ): Promise<PaymentResponse>;
+  getPaymentStatus(paymentId: string): Promise<PaymentStatusResponse>;
+  createRefund(
+    params: CreateRefundParams,
+    idempotencyKey?: string,
+  ): Promise<RefundResponse>;
+  cancelPayment(
+    params: CancelPaymentParams,
+    idempotencyKey?: string,
+  ): Promise<CancelPaymentResponse>;
+  // Можно добавить getRefundStatus(refundId: string): Promise<RefundStatusResponse>;
+}

+ 295 - 0
src/modules/client/shop/payment/payment-service.ts

@@ -0,0 +1,295 @@
+import { selPool, updPool } from "#db/db.js";
+import { DatabaseTransactionConnection, sql } from "slonik";
+import {
+  IPaymentProvider,
+  CreatePaymentParams,
+  CreateRefundParams,
+  RefundResponse,
+  PaymentStatusResponse,
+  CancelPaymentParams,
+  CancelPaymentResponse,
+} from "./payment-provider-types.js";
+import { v7 as uuidv7 } from "uuid";
+import { logger } from "#plugins/logger.js";
+import { dayjs } from "#plugins/dayjs.js";
+import { DbSchema } from "#db/db-schema.js";
+import { z } from "zod";
+import { PaymentProviderError } from "./shop-errors.js";
+
+export class PaymentService {
+  private provider: IPaymentProvider;
+
+  constructor(provider: IPaymentProvider) {
+    this.provider = provider;
+  }
+
+  async createPayment(
+    tr: DatabaseTransactionConnection,
+    params: CreatePaymentParams,
+  ) {
+    const paymentId = uuidv7();
+
+    logger.info(`Создание платежа: ${paymentId}...`);
+
+    const paymentResponse = await this.provider.createPayment(
+      params,
+      paymentId,
+    );
+    const payment = await tr.one(sql.type(
+      z.object({
+        paymentId: DbSchema.shop.payments.paymentId,
+        orderId: DbSchema.shop.payments.orderId,
+        userId: DbSchema.shop.payments.userId,
+        amount: DbSchema.shop.payments.amount,
+        currencyCode: DbSchema.shop.payments.currencyCode,
+        paymentMethod: DbSchema.shop.payments.paymentMethod,
+        bank: DbSchema.shop.payments.bank,
+        status: DbSchema.shop.payments.status,
+        externalTransactionId: DbSchema.shop.payments.externalTransactionId,
+        paymentGatewayDetails: DbSchema.shop.payments.paymentGatewayDetails,
+        createdAt: DbSchema.shop.payments.createdAt,
+        updatedAt: DbSchema.shop.payments.updatedAt,
+        confirmation: DbSchema.shop.payments.confirmation,
+      }),
+    )`
+      insert into shop.payments (
+        payment_id, 
+        order_id, 
+        user_id, 
+        amount, 
+        currency_code, 
+        payment_method, 
+        bank,
+        status, 
+        external_transaction_id, 
+        payment_gateway_details, 
+        created_at,
+        confirmation
+      ) values (
+        ${paymentId}, 
+        ${params.orderId}, 
+        ${params.userId}, 
+        ${paymentResponse.amount.value}, 
+        ${paymentResponse.amount.currency}, 
+        'CARD', 
+        ${params.bank}, 
+        'PENDING', 
+        ${paymentResponse.id}, 
+        ${sql.jsonb(paymentResponse.providerSpecificDetails)}, 
+        ${paymentResponse.createdAt},
+        ${paymentResponse.confirmation ? sql.jsonb(paymentResponse.confirmation) : null}
+      ) returning
+        payment_id as "paymentId",
+        order_id as "orderId",
+        user_id as "userId",
+        amount::float as "amount",
+        currency_code as "currencyCode",
+        payment_method as "paymentMethod",
+        bank,
+        status,
+        external_transaction_id as "externalTransactionId",
+        payment_gateway_details as "paymentGatewayDetails",
+        created_at as "createdAt",
+        updated_at as "updatedAt",
+        confirmation
+    `);
+
+    const paymentDueDate = dayjs().add(1, "hour").toDate();
+
+    await tr.query(sql.unsafe`
+      update shop.orders
+      set status = 'PENDING_PAYMENT', 
+      payment_due_date = ${paymentDueDate.toISOString()}
+      where order_id = ${params.orderId}
+    `);
+
+    return payment;
+  }
+
+  async getPaymentStatus(paymentId: string): Promise<PaymentStatusResponse> {
+    return this.provider.getPaymentStatus(paymentId);
+  }
+
+  async refundPayment(params: CreateRefundParams): Promise<RefundResponse> {
+    // Аналогично, можно добавить логику
+    const idempotencyKey = uuidv7();
+    console.log(
+      `Creating refund for payment ${params.paymentId} with idempotency key: ${idempotencyKey}`,
+    );
+    return this.provider.createRefund(params, idempotencyKey);
+  }
+
+  // Новая функция для отмены платежа
+  async cancelPayment(
+    // tr: DatabaseTransactionConnection,
+    paymentIdToCancel: string,
+  ): Promise<CancelPaymentResponse> {
+    const idempotencyKey = uuidv7();
+    logger.info(
+      `Попытка отмены платежа: ${paymentIdToCancel} с ключом идемпотентности: ${idempotencyKey}`,
+    );
+
+    // Получаем order_id перед отменой, чтобы обновить заказ
+    const paymentDetails = await selPool.maybeOne(sql.type(
+      z.object({
+        orderId: DbSchema.shop.payments.orderId,
+        status: DbSchema.shop.payments.status,
+      }),
+    )`
+        select order_id as "orderId", status from shop.payments where payment_id = ${paymentIdToCancel}
+    `);
+
+    if (!paymentDetails) {
+      logger.error(`Платеж ${paymentIdToCancel} не найден для отмены.`);
+      throw new PaymentProviderError(
+        `Payment with ID ${paymentIdToCancel} not found.`,
+        { paymentId: paymentIdToCancel },
+      );
+    }
+
+    if (paymentDetails.status === "CANCELED") {
+      logger.warn(`Платеж ${paymentIdToCancel} уже отменен.`);
+      // Можно вернуть текущий статус или специальный ответ
+      // Для простоты, запросим статус у провайдера, чтобы вернуть актуальные данные
+      return this.provider.getPaymentStatus(paymentIdToCancel);
+    }
+    if (paymentDetails.status === "SUCCEEDED") {
+      logger.error(
+        `Платеж ${paymentIdToCancel} уже успешно выполнен и не может быть отменен. Используйте возврат.`,
+      );
+      throw new PaymentProviderError(
+        `Payment ${paymentIdToCancel} is already succeeded and cannot be canceled. Use refund instead.`,
+      );
+    }
+
+    const cancelParams: CancelPaymentParams = { paymentId: paymentIdToCancel };
+    const cancelResponse = await this.provider.cancelPayment(
+      cancelParams,
+      idempotencyKey,
+    );
+
+    logger.info(
+      `Ответ от провайдера по отмене платежа ${paymentIdToCancel}: статус ${cancelResponse.status}`,
+    );
+
+    // Обновляем статус в нашей БД, если провайдер подтвердил отмену или платеж уже был отменен у провайдера
+    if (cancelResponse.status === "canceled") {
+      await updPool.query(sql.unsafe`
+        update shop.payments
+        set status = 'CANCELED'
+        where payment_id = ${paymentIdToCancel}
+      `);
+      logger.info(
+        `Статус платежа ${paymentIdToCancel} обновлен на CANCELED в БД.`,
+      );
+
+      if (paymentDetails.orderId) {
+        await updPool.query(sql.unsafe`
+          update shop.orders
+          set status = 'CANCELED'
+          where order_id = ${paymentDetails.orderId} 
+          and status = 'PENDING_PAYMENT' -- Обновляем только если заказ ожидал оплату
+        `);
+        logger.info(
+          `Статус заказа ${paymentDetails.orderId} обновлен на CANCELED в БД.`,
+        );
+      }
+    } else {
+      // Если провайдер не вернул 'canceled', но и не выбросил ошибку (маловероятно для API отмены, но для полноты)
+      // Можно обновить статус на тот, что вернул провайдер
+      await updPool.query(sql.unsafe`
+        update shop.payments
+        set status = 'FAILED'
+        where payment_id = ${paymentIdToCancel}
+      `);
+      logger.warn(
+        `Платеж ${paymentIdToCancel} не был отменен провайдером, текущий статус от провайдера: ${cancelResponse.status}. Статус в БД обновлен.`,
+      );
+    }
+
+    return cancelResponse;
+  }
+
+  async waitPayment(paymentId: string, endDate: string) {
+    const payment = await selPool.maybeOne(sql.type(
+      z.object({
+        paymentId: DbSchema.shop.payments.paymentId,
+        orderId: DbSchema.shop.payments.orderId,
+        status: DbSchema.shop.payments.status,
+        externalTransactionId: DbSchema.shop.payments.externalTransactionId,
+      }),
+    )`
+      select payment_id as "paymentId",
+      order_id as "orderId",
+      status,
+      external_transaction_id as "externalTransactionId"
+      from shop.payments
+      where payment_id = ${paymentId}
+    `);
+
+    if (!payment) {
+      logger.error(`Платеж ${paymentId} не найден.`);
+      throw new PaymentProviderError(
+        `Payment with ID ${paymentId} not found.`,
+        { paymentId },
+      );
+    }
+
+    if (!payment.externalTransactionId) {
+      logger.error(`externalTransactionId платежа ${paymentId} не найден.`);
+      throw new PaymentProviderError(
+        `externalTransactionId платежа ${paymentId} не найден.`,
+        { paymentId },
+      );
+    }
+
+    do {
+      logger.info(`Ожидание платежа: ${paymentId}...`);
+      const paymentStatus = await this.getPaymentStatus(
+        payment.externalTransactionId,
+      );
+      logger.info(`Статус платежа: ${paymentStatus.status}`);
+      if (
+        paymentStatus.status === "succeeded" ||
+        paymentStatus.status === "waiting_for_capture"
+      ) {
+        await updPool.query(sql.unsafe`
+          update shop.payments
+          set status = 'SUCCEEDED'
+          where payment_id = ${paymentId}
+        `);
+
+        return "succeeded";
+      }
+
+      if (paymentStatus.status === "canceled") {
+        logger.info(`Платеж отменен провайдером: ${paymentId}`);
+        await updPool.query(sql.unsafe`
+          update shop.payments
+          set status = 'CANCELED'
+          where payment_id = ${paymentId}
+        `);
+        return "canceled";
+      }
+
+      if (paymentStatus.status === "pending") {
+        await new Promise((resolve) => setTimeout(resolve, 5000));
+        continue;
+      }
+
+      logger.error(
+        `Неизвестный статус платежа ${paymentId}: ${paymentStatus.status}`,
+      );
+      return "failed";
+    } while (dayjs().isBefore(endDate));
+
+    logger.info(`Платеж отменен из-за времени ожидания: ${paymentId}`);
+    await this.cancelPayment(paymentId);
+    await updPool.query(sql.unsafe`
+      update shop.payments
+      set status = 'CANCELED'
+      where payment_id = ${paymentId}
+    `);
+    return "canceled";
+  }
+}

+ 47 - 0
src/modules/client/shop/payment/shop-errors.ts

@@ -0,0 +1,47 @@
+export class PaymentError extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = this.constructor.name;
+  }
+}
+
+export class ConfigurationError extends PaymentError {
+  constructor(message: string) {
+    super(message);
+  }
+}
+
+export class PaymentProviderError extends PaymentError {
+  public readonly providerDetails?: unknown;
+  constructor(message: string, providerDetails?: unknown) {
+    super(message);
+    this.providerDetails = providerDetails;
+  }
+}
+
+export class YooKassaApiError extends PaymentProviderError {
+  public readonly type?: string;
+  public readonly ykId?: string; // Renamed from 'id' to avoid conflict with Error.id if it existed
+  public readonly code?: string;
+  public readonly description?: string;
+  public readonly parameter?: string;
+
+  constructor(
+    message: string,
+    details: {
+      type?: string;
+      id?: string;
+      code?: string;
+      description?: string;
+      parameter?: string;
+      [key: string]: unknown; // To capture other potential fields
+    },
+  ) {
+    super(message, details);
+    this.type = details.type;
+    this.ykId = details.id;
+    this.code = details.code;
+    this.description = details.description;
+    this.parameter = details.parameter;
+  }
+}

+ 356 - 0
src/modules/client/shop/payment/yookassa-provider.ts

@@ -0,0 +1,356 @@
+import axios, { AxiosInstance, AxiosError } from "axios";
+// import { paymentConfig } from '../config/payment.config';
+import {
+  IPaymentProvider,
+  CreatePaymentParams,
+  PaymentResponse,
+  CreateRefundParams,
+  RefundResponse,
+  PaymentStatusResponse,
+  CancelPaymentParams,
+  CancelPaymentResponse,
+} from "./payment-provider-types.js";
+import {
+  YooKassaCreatePaymentRequest,
+  YooKassaPaymentResponse,
+  YooKassaCreateRefundRequest,
+  YooKassaRefundResponse,
+  YooKassaErrorResponseSchema,
+  YooKassaCreatePaymentRequestSchema,
+  YooKassaPaymentResponseSchema,
+  YooKassaCreateRefundRequestSchema,
+  YooKassaRefundResponseSchema,
+} from "./yookassa-types.js";
+import { YooKassaApiError, PaymentProviderError } from "./shop-errors.js";
+import { v4 } from "uuid";
+import { z } from "zod";
+
+export class YooKassaProvider implements IPaymentProvider {
+  private readonly axiosInstance: AxiosInstance;
+  private readonly shopId: string;
+  private readonly secretKey: string;
+  private readonly apiUrl: string;
+
+  constructor() {
+    // TODO: вынести в таблицу
+    this.shopId = "1027499";
+    this.secretKey = "test_iTpukmMRAmujJSvL2WqoD-VNxp_bmvtjIkELGRsiUys";
+    this.apiUrl = "https://api.yookassa.ru/v3";
+
+    if (!this.shopId || !this.secretKey) {
+      throw new Error("YooKassa Shop ID or Secret Key is not configured.");
+    }
+
+    this.axiosInstance = axios.create({
+      baseURL: this.apiUrl,
+      headers: {
+        "Content-Type": "application/json",
+        // Basic Auth: base64(shopId:secretKey)
+        Authorization: `Basic ${Buffer.from(`${this.shopId}:${this.secretKey}`).toString("base64")}`,
+      },
+      timeout: 15000, // 15 секунд таймаут
+    });
+  }
+
+  private handleError(error: unknown): never {
+    if (axios.isAxiosError(error)) {
+      const axiosError = error as AxiosError;
+      if (axiosError.response && axiosError.response.data) {
+        const parsedError = YooKassaErrorResponseSchema.safeParse(
+          axiosError.response.data,
+        );
+        if (parsedError.success) {
+          const ykError = parsedError.data;
+          throw new YooKassaApiError(
+            ykError.description ||
+              `YooKassa API Error: ${ykError.code || "Unknown code"}`,
+            ykError,
+          );
+        } else {
+          // Если структура ошибки YooKassa не распознана, выбрасываем более общую ошибку
+          throw new PaymentProviderError(
+            `YooKassa API request failed with status ${axiosError.response.status}: ${JSON.stringify(axiosError.response.data)}`,
+            axiosError.response.data,
+          );
+        }
+      }
+      throw new PaymentProviderError(
+        `YooKassa request failed: ${axiosError.message}`,
+      );
+    }
+    // Если это не ошибка Axios, но все же ошибка
+    if (error instanceof Error) {
+      throw new PaymentProviderError(
+        `An unexpected error occurred: ${error.message}`,
+      );
+    }
+    // Для совсем неизвестных случаев
+    throw new PaymentProviderError(
+      "An unknown error occurred during the YooKassa request.",
+    );
+  }
+
+  private mapToYooKassaPaymentRequest(
+    params: CreatePaymentParams,
+  ): YooKassaCreatePaymentRequest {
+    const ykRequest: YooKassaCreatePaymentRequest = {
+      amount: {
+        value: params.amount.value,
+        currency: params.amount.currency.toUpperCase(),
+      },
+      capture: params.capture,
+      description: params.description,
+      confirmation: {
+        // YooKassa требует confirmation, если не передается payment_method_data
+        type: "redirect",
+        return_url: params.returnUrl,
+      },
+      metadata: params.metadata,
+    };
+    if (params.paymentMethodType) {
+      // Если тип метода указан, YooKassa может не требовать confirmation
+      // Но если он указан, то return_url все равно нужен для redirect
+      // Это упрощение, для production надо будет точнее смотреть по API YooKassa
+      // для каждого payment_method_data
+
+      const types = {
+        CARD: "bank_card",
+      };
+
+      const formatedType = types[params.paymentMethodType];
+
+      ykRequest.payment_method_data = { type: formatedType };
+      // Если указан payment_method_data, confirmation становится опциональным
+      // Однако, если мы хотим редирект, то confirmation всё равно нужен
+      // Для простоты, оставляем confirmation всегда, если нужен редирект
+    }
+
+    // Валидация Zod перед отправкой (опционально, но полезно для отладки)
+    YooKassaCreatePaymentRequestSchema.parse(ykRequest);
+    return ykRequest;
+  }
+
+  private mapToGenericPaymentResponse(
+    ykResponse: YooKassaPaymentResponse,
+  ): PaymentResponse {
+    return {
+      id: ykResponse.id,
+      status: ykResponse.status,
+      paid: ykResponse.paid,
+      amount: {
+        value: ykResponse.amount.value,
+        currency: ykResponse.amount.currency,
+      },
+      confirmation: ykResponse.confirmation
+        ? {
+            type: ykResponse.confirmation.type,
+            confirmationUrl: ykResponse.confirmation.confirmation_url,
+          }
+        : undefined,
+      createdAt: ykResponse.created_at,
+      description: ykResponse.description,
+      metadata: ykResponse.metadata,
+      providerSpecificDetails: {
+        // Можно добавить сюда все, что не вошло в общую модель
+        test: ykResponse.test,
+        expires_at: ykResponse.expires_at,
+        payment_method: ykResponse.payment_method,
+        refundable: ykResponse.refundable,
+        // ... etc
+      },
+    };
+  }
+
+  private mapToGenericPaymentStatusResponse(
+    ykResponse: YooKassaPaymentResponse,
+  ): PaymentStatusResponse {
+    // Используем логику из mapToGenericPaymentResponse и добавляем/убираем поля
+    const genericResponse = this.mapToGenericPaymentResponse(ykResponse);
+    delete genericResponse.confirmation; // confirmation не нужен в статусе
+
+    return {
+      ...genericResponse,
+      test: ykResponse.test,
+      incomeAmount: ykResponse.income_amount
+        ? {
+            value: ykResponse.income_amount.value,
+            currency: ykResponse.income_amount.currency,
+          }
+        : undefined,
+      refundedAmount: ykResponse.refunded_amount
+        ? {
+            value: ykResponse.refunded_amount.value,
+            currency: ykResponse.refunded_amount.currency,
+          }
+        : undefined,
+    };
+  }
+
+  async createPayment(
+    params: CreatePaymentParams,
+    idempotencyKey?: string,
+  ): Promise<PaymentResponse> {
+    const key = idempotencyKey || v4();
+    const requestBody = this.mapToYooKassaPaymentRequest(params);
+
+    try {
+      // Валидируем исходящий запрос нашей Zod схемой
+      YooKassaCreatePaymentRequestSchema.parse(requestBody);
+
+      const response = await this.axiosInstance.post<YooKassaPaymentResponse>(
+        "/payments",
+        requestBody,
+        {
+          headers: { "Idempotence-Key": key },
+        },
+      );
+
+      // Валидируем входящий ответ Zod схемой
+      const parsedData = YooKassaPaymentResponseSchema.parse(response.data);
+      return this.mapToGenericPaymentResponse(parsedData);
+    } catch (error) {
+      if (error instanceof z.ZodError) {
+        throw new PaymentProviderError(
+          `YooKassa data validation error: ${error.message}`,
+          error.format(),
+        );
+      }
+      this.handleError(error);
+    }
+  }
+
+  // >>> НОВАЯ ФУНКЦИЯ ОТМЕНЫ ПЛАТЕЖА <<<
+  async cancelPayment(
+    params: CancelPaymentParams,
+    idempotencyKey?: string,
+  ): Promise<CancelPaymentResponse> {
+    const key = idempotencyKey || v4();
+    const { paymentId } = params;
+
+    if (!paymentId) {
+      // Дополнительная проверка, хотя Zod должен это отловить на уровне вызова
+      throw new PaymentProviderError(
+        "paymentId is required to cancel a payment.",
+      );
+    }
+
+    try {
+      // API YooKassa для отмены платежа: POST /v3/payments/{payment_id}/cancel
+      // Тело запроса пустое.
+      const response = await this.axiosInstance.post<YooKassaPaymentResponse>(
+        `/payments/${paymentId}/cancel`,
+        {}, // Пустое тело запроса
+        {
+          headers: { "Idempotence-Key": key },
+        },
+      );
+
+      // Ответ от YooKassa при отмене - это обновленный объект платежа.
+      // Валидируем его с помощью существующей схемы YooKassaPaymentResponseSchema.
+      const parsedData = YooKassaPaymentResponseSchema.parse(response.data);
+
+      // Маппим ответ YooKassa на наш общий CancelPaymentResponse,
+      // который по структуре идентичен PaymentStatusResponse.
+      return this.mapToGenericPaymentStatusResponse(parsedData);
+    } catch (error) {
+      if (error instanceof z.ZodError) {
+        // Ошибка валидации ответа от YooKassa
+        throw new PaymentProviderError(
+          `YooKassa data validation error for cancelPayment response: ${error.message}`,
+          error.format(),
+        );
+      }
+      // Обработка ошибок Axios и других ошибок
+      this.handleError(error);
+    }
+  }
+  // >>> КОНЕЦ НОВОЙ ФУНКЦИИ <<<
+
+  async getPaymentStatus(paymentId: string): Promise<PaymentStatusResponse> {
+    try {
+      const response = await this.axiosInstance.get<YooKassaPaymentResponse>(
+        `/payments/${paymentId}`,
+      );
+      const parsedData = YooKassaPaymentResponseSchema.parse(response.data);
+      return this.mapToGenericPaymentStatusResponse(parsedData);
+    } catch (error) {
+      if (error instanceof z.ZodError) {
+        throw new PaymentProviderError(
+          `YooKassa data validation error for getPaymentStatus: ${error.message}`,
+          error.format(),
+        );
+      }
+      this.handleError(error);
+    }
+  }
+
+  private mapToYooKassaRefundRequest(
+    params: CreateRefundParams,
+  ): YooKassaCreateRefundRequest {
+    const ykRequest: YooKassaCreateRefundRequest = {
+      payment_id: params.paymentId,
+      amount: {
+        value: params.amount.value,
+        currency: params.amount.currency.toUpperCase(),
+      },
+      description: params.description,
+      // metadata не поддерживается API YooKassa для возвратов напрямую,
+      // но если бы поддерживалось, добавили бы сюда params.metadata
+    };
+    // YooKassaCreateRefundRequestSchema.parse(ykRequest); // Опциональная валидация
+    return ykRequest;
+  }
+
+  private mapToGenericRefundResponse(
+    ykResponse: YooKassaRefundResponse,
+  ): RefundResponse {
+    return {
+      id: ykResponse.id,
+      paymentId: ykResponse.payment_id,
+      status: ykResponse.status,
+      amount: {
+        value: ykResponse.amount.value,
+        currency: ykResponse.amount.currency,
+      },
+      createdAt: ykResponse.created_at,
+      description: ykResponse.description,
+      providerSpecificDetails: {
+        receipt_registration: ykResponse.receipt_registration,
+        // ... etc
+      },
+    };
+  }
+
+  async createRefund(
+    params: CreateRefundParams,
+    idempotencyKey?: string,
+  ): Promise<RefundResponse> {
+    const key = idempotencyKey || v4();
+    const requestBody = this.mapToYooKassaRefundRequest(params);
+
+    try {
+      // Валидируем исходящий запрос
+      YooKassaCreateRefundRequestSchema.parse(requestBody);
+
+      const response = await this.axiosInstance.post<YooKassaRefundResponse>(
+        "/refunds",
+        requestBody,
+        {
+          headers: { "Idempotence-Key": key },
+        },
+      );
+
+      // Валидируем входящий ответ
+      const parsedData = YooKassaRefundResponseSchema.parse(response.data);
+      return this.mapToGenericRefundResponse(parsedData);
+    } catch (error) {
+      if (error instanceof z.ZodError) {
+        throw new PaymentProviderError(
+          `YooKassa data validation error for refund: ${error.message}`,
+          error.format(),
+        );
+      }
+      this.handleError(error);
+    }
+  }
+}

+ 126 - 0
src/modules/client/shop/payment/yookassa-types.ts

@@ -0,0 +1,126 @@
+import { z } from "zod";
+
+// --- Схемы для YooKassa API ---
+// Основаны на документации: https://yookassa.ru/developers/api
+
+export const YooKassaAmountSchema = z.object({
+  value: z.string().regex(/^\d+\.\d{2}$/),
+  currency: z.string().length(3).toUpperCase(), // "RUB"
+});
+
+export const YooKassaConfirmationSchema = z.object({
+  type: z.enum([
+    "redirect",
+    "qr",
+    "embedded",
+    "external",
+    "mobile_application",
+  ]),
+  return_url: z.string().url().optional(), // Обязателен для type: 'redirect'
+  confirmation_url: z.string().url().optional(), // Для type: 'redirect'
+  confirmation_token: z.string().optional(), // Для type: 'embedded' или 'mobile_application'
+  confirmation_data: z.string().optional(), // Для type: 'qr' (data для генерации QR)
+  enforce: z.boolean().optional(), // Для type: 'redirect'
+});
+
+export const YooKassaPaymentMethodDataSchema = z
+  .object({
+    type: z.string(), // e.g. "bank_card", "sbp", "yoo_money"
+    // ... другие поля в зависимости от type
+  })
+  .passthrough(); // Разрешаем другие поля, т.к. их много для разных методов
+
+export const YooKassaCreatePaymentRequestSchema = z
+  .object({
+    amount: YooKassaAmountSchema,
+    description: z.string().max(128).optional(),
+    capture: z.boolean().default(true),
+    confirmation: YooKassaConfirmationSchema.optional(), // Будет обязательным, если payment_method_data не указан
+    payment_method_data: YooKassaPaymentMethodDataSchema.optional(), // Если указан, confirmation может быть не нужен
+    receipt: z.any().optional(), // Схема для чека (сложная, пока опустим для простоты)
+    metadata: z
+      .record(z.string().max(512), z.string().max(512))
+      .refine((obj) => Object.keys(obj).length <= 16, {
+        message: "Metadata can have at most 16 key-value pairs",
+      })
+      .optional(),
+    save_payment_method: z.boolean().optional(),
+    // ... и другие поля согласно API YooKassa
+  })
+  .refine((data) => data.confirmation || data.payment_method_data, {
+    message: "Either 'confirmation' or 'payment_method_data' must be provided",
+    path: ["confirmation", "payment_method_data"], // Указываем путь для ошибки
+  });
+export type YooKassaCreatePaymentRequest = z.infer<
+  typeof YooKassaCreatePaymentRequestSchema
+>;
+
+export const YooKassaPaymentResponseSchema = z
+  .object({
+    id: z.string().uuid(),
+    status: z.enum(["pending", "waiting_for_capture", "succeeded", "canceled"]),
+    paid: z.boolean(),
+    amount: YooKassaAmountSchema,
+    created_at: z.string().datetime(),
+    description: z.string().optional().nullable(),
+    expires_at: z.string().datetime().optional(),
+    metadata: z.record(z.any()).optional().nullable(),
+    payment_method: YooKassaPaymentMethodDataSchema.optional(),
+    confirmation: YooKassaConfirmationSchema.optional(), // Может отсутствовать, если платеж уже succeeded
+    captured_at: z.string().datetime().optional(),
+    receipt_registration: z
+      .enum(["pending", "succeeded", "canceled"])
+      .optional(),
+    refundable: z.boolean().optional(),
+    test: z.boolean(),
+    income_amount: YooKassaAmountSchema.optional(),
+    refunded_amount: YooKassaAmountSchema.optional(),
+    // ... другие поля
+  })
+  .passthrough(); // API может возвращать доп. поля
+export type YooKassaPaymentResponse = z.infer<
+  typeof YooKassaPaymentResponseSchema
+>;
+
+export const YooKassaCreateRefundRequestSchema = z.object({
+  payment_id: z.string().uuid(),
+  amount: YooKassaAmountSchema,
+  description: z.string().max(250).optional(), // Причина возврата
+  receipt: z.any().optional(), // Схема для чека возврата
+  sources: z.any().optional(), // Для сложных возвратов по частям от разных источников
+  deal: z.object({ id: z.string() }).optional(), // Для безопасной сделки
+});
+export type YooKassaCreateRefundRequest = z.infer<
+  typeof YooKassaCreateRefundRequestSchema
+>;
+
+export const YooKassaRefundResponseSchema = z
+  .object({
+    id: z.string().uuid(),
+    payment_id: z.string().uuid(),
+    status: z.enum(["pending", "succeeded", "canceled"]),
+    created_at: z.string().datetime(),
+    amount: YooKassaAmountSchema,
+    description: z.string().optional().nullable(),
+    receipt_registration: z
+      .enum(["pending", "succeeded", "canceled"])
+      .optional(),
+    deal: z
+      .object({ id: z.string(), refund_settlements: z.array(z.any()) })
+      .optional(),
+  })
+  .passthrough();
+export type YooKassaRefundResponse = z.infer<
+  typeof YooKassaRefundResponseSchema
+>;
+
+export const YooKassaErrorResponseSchema = z
+  .object({
+    type: z.string().optional(), // "error"
+    id: z.string().uuid().optional(), // request_id
+    code: z.string().optional(), // e.g. "invalid_request", "invalid_credentials"
+    description: z.string().optional(), // "Invalid API key"
+    parameter: z.string().optional(), // e.g. "payment_token"
+  })
+  .passthrough();
+export type YooKassaErrorResponse = z.infer<typeof YooKassaErrorResponseSchema>;

+ 118 - 0
src/test-yookassa.ts

@@ -0,0 +1,118 @@
+// main.ts или app.ts
+import { YooKassaProvider } from "./modules/client/shop/yookassa-provider.js";
+import { PaymentService } from "./modules/client/shop/payment-service.js";
+import {
+  CreatePaymentParamsSchema,
+  CreateRefundParamsSchema,
+} from "./modules/client/shop/payment-provider-types.js";
+import {
+  YooKassaApiError,
+  PaymentProviderError,
+} from "./modules/client/shop/shop-errors.js";
+import "dotenv/config"; // Убедитесь, что .env загружен
+import { z } from "zod";
+
+async function run() {
+  // 1. Инициализация провайдера
+  const yookassaProvider = new YooKassaProvider();
+
+  // 2. Инициализация сервиса с конкретным провайдером
+  const paymentService = new PaymentService(yookassaProvider);
+
+  // 3. Создание платежа
+  try {
+    const paymentParams = CreatePaymentParamsSchema.parse({
+      amount: { value: "100.00", currency: "RUB" },
+      description: "Заказ №12345. Тестовый платеж.",
+      returnUrl: "https://dsfgsdfgstrgdsgfsdfgsdgfsdfg.рф/payment-success", // Замените на ваш URL
+      // metadata: { order_id: '12345', user_id: 'user-test-789' }, // YooKassa требует string:string
+      metadata: { order_id: "12345" },
+      capture: true,
+      // paymentMethodType: "bank_card" // Можно указать конкретный метод
+    });
+
+    console.log("Попытка создать платёж...");
+    const paymentResponse = await paymentService.createPayment(paymentParams);
+    console.log("Платёж создан:");
+    console.log("Payment ID:", paymentResponse.id);
+    console.log("Status:", paymentResponse.status);
+    if (paymentResponse.confirmation?.confirmationUrl) {
+      console.log(
+        "Confirmation URL:",
+        paymentResponse.confirmation.confirmationUrl,
+      );
+      console.log(
+        "--- Пожалуйста, перейдите по этому адресу, чтобы завершить платеж ---",
+      );
+    } else {
+      console.log(
+        "URL-адрес для перенаправления отсутствует, платеж может быть обработан по-другому или уже завершен / не выполнен с ошибкой.",
+      );
+    }
+
+    // 4. Получение статуса платежа (после того как пользователь что-то сделал на стороне YooKassa)
+    // В реальном приложении это будет делаться по webhook или по запросу пользователя
+    let isStop = false;
+    const checkPaymentStatus = async () => {
+      if (
+        paymentResponse.status === "pending" ||
+        (paymentResponse.status === "waiting_for_capture" && !isStop)
+      ) {
+        console.log(
+          `\проверка статуса идентификатора платежа: ${paymentResponse.id}`,
+        );
+        const statusResponse = await paymentService.getPaymentStatus(
+          paymentResponse.id,
+        );
+        console.log("Статус платежа:", statusResponse);
+
+        // 5. Если платеж прошел (succeeded) и capture: true, можно делать возврат
+        // Для теста, предположим, что он прошел (YooKassa может сразу сделать succeeded для тестовых карт)
+        // В реальности, вы дождетесь статуса succeeded.
+        if (statusResponse.status === "succeeded") {
+          console.log("\n Попытка вернуть платеж...");
+          const refundParams = CreateRefundParamsSchema.parse({
+            paymentId: paymentResponse.id,
+            amount: { value: "50.00", currency: "RUB" }, // Частичный возврат
+            description: "Возврат по заказу №12345. Частичный.",
+          });
+          const refundResponse =
+            await paymentService.refundPayment(refundParams);
+          console.log("Начат возврат средств:");
+          console.log("ID возврата:", refundResponse.id);
+          console.log("Статус возврата:", refundResponse.status);
+          isStop = true;
+        } else {
+          console.log(
+            `\nСтатус платежа пока ${statusResponse.status}. Возврат пока невозможен.`,
+          );
+        }
+      }
+    };
+
+    setTimeout(() => setInterval(checkPaymentStatus, 5000), 5000); // 5 секунд
+  } catch (error) {
+    if (error instanceof z.ZodError) {
+      console.error("Validation error:", error.format());
+    } else if (error instanceof YooKassaApiError) {
+      console.error("YooKassa API Error:", error.message);
+      console.error("Details:", {
+        type: error.type,
+        id: error.ykId,
+        code: error.code,
+        description: error.description,
+        parameter: error.parameter,
+        providerDetails: error.providerDetails,
+      });
+    } else if (error instanceof PaymentProviderError) {
+      console.error("Payment Provider Error:", error.message);
+      if (error.providerDetails) {
+        console.error("Provider Details:", error.providerDetails);
+      }
+    } else {
+      console.error("An unexpected error occurred:", error);
+    }
+  }
+}
+
+run();