Browse Source

Улучшение системы оплаты

Vadim 2 months ago
parent
commit
566bfa1597

+ 5 - 1
.example.env

@@ -28,4 +28,8 @@ BCRYPT_SALT_ROUNDS = "12"
 
 # CONFIRM PINS
 CONFIRM_PIN_LIFETIME_MINS = "60"
-CONFIRM_PIN_MAX_TRIES = "5"
+CONFIRM_PIN_MAX_TRIES = "5"
+
+# PROVIDERS
+YOOOKASSA_SHOP_ID = ""
+YOOOKASSA_SECRET_KEY = ""

+ 22 - 4
package-lock.json

@@ -16,10 +16,12 @@
         "dayjs": "^1.11.13",
         "dotenv": "^16.4.7",
         "express": "^4.21.2",
+        "ipaddr.js": "^2.2.0",
         "jsonwebtoken": "^9.0.2",
         "log4js": "^6.9.1",
         "multer": "^1.4.5-lts.1",
         "music-metadata": "^11.2.3",
+        "node-cron": "^4.1.1",
         "nodemailer": "^6.10.0",
         "pg": "^8.13.3",
         "slonik": "^37.6.0",
@@ -2676,11 +2678,11 @@
       "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
     },
     "node_modules/ipaddr.js": {
-      "version": "1.9.1",
-      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
-      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
+      "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==",
       "engines": {
-        "node": ">= 0.10"
+        "node": ">= 10"
       }
     },
     "node_modules/is-arrayish": {
@@ -3244,6 +3246,14 @@
       "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
       "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA=="
     },
+    "node_modules/node-cron": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.1.1.tgz",
+      "integrity": "sha512-oJj9CYV7teeCVs+y2Efi5IQ4FGmAYbsXQOehc1AGLlwteec8pC7DjBCUzSyRQ0LYa+CRCgmD+vtlWQcnPpXowA==",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
     "node_modules/node-fetch": {
       "version": "2.7.0",
       "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -3731,6 +3741,14 @@
         "node": ">= 0.10"
       }
     },
+    "node_modules/proxy-addr/node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "engines": {
+        "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",

+ 2 - 0
package.json

@@ -36,10 +36,12 @@
     "dayjs": "^1.11.13",
     "dotenv": "^16.4.7",
     "express": "^4.21.2",
+    "ipaddr.js": "^2.2.0",
     "jsonwebtoken": "^9.0.2",
     "log4js": "^6.9.1",
     "multer": "^1.4.5-lts.1",
     "music-metadata": "^11.2.3",
+    "node-cron": "^4.1.1",
     "nodemailer": "^6.10.0",
     "pg": "^8.13.3",
     "slonik": "^37.6.0",

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

@@ -162,18 +162,6 @@ class ClientShopApi {
       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();

+ 4 - 0
src/config/config.ts

@@ -81,6 +81,10 @@ const ConfigSchema = z.object({
   // CONFIRM PINS
   CONFIRM_PIN_LIFETIME_MINS: requiredNumber("CONFIRM_PIN_LIFETIME_MINS", 10),
   CONFIRM_PIN_MAX_TRIES: requiredNumber("CONFIRM_PIN_MAX_TRIES", 3),
+
+  // PROVIDERS
+  YOOOKASSA_SHOP_ID: requiredString("YOOOKASSA_SHOP_ID"),
+  YOOOKASSA_SECRET_KEY: requiredString("YOOOKASSA_SECRET_KEY"),
 });
 
 // Проверяем валидность конфигурации

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

@@ -405,7 +405,7 @@ const DbSchema = {
         "REFUNDED",
         "CANCELED",
       ]),
-      externalTransactionId: z.string().nullable(),
+      externalTransactionId: z.string(),
       paymentGatewayDetails: z.any().nullable(),
       createdAt: z.string().datetime(),
       updatedAt: z.string().datetime(),
@@ -421,6 +421,20 @@ const DbSchema = {
           confirmationUrl: z.string().url().nullable(),
         })
         .nullable(),
+      refundedAmount: z.number().nullable(),
+    },
+    refunds: {
+      refundId: z.string().uuid(),
+      paymentId: z.string().uuid(),
+      orderItemId: z.string().uuid().nullable(),
+      externalRefundId: z.string().uuid(),
+      amount: z.number(),
+      currencyCode: z.string().length(3),
+      status: z.enum(["PENDING", "SUCCEEDED", "FAILED", "CANCELED"]),
+      reason: z.string(),
+      createdAt: z.string().datetime(),
+      updatedAt: z.string().datetime(),
+      providerDetails: z.any().nullable(),
     },
   },
 };

+ 8 - 4
src/main.ts

@@ -10,6 +10,8 @@ const app = express();
 
 app.use(express.json());
 
+app.set("trust proxy", true);
+
 // cookie-parser
 import cookieParser from "cookie-parser";
 app.use(cookieParser());
@@ -39,6 +41,10 @@ app.use((req, res, next) => {
 
 logger.info("Импорт роутеров...");
 
+// payment-webhook
+import paymentWebhookRouter from "./modules/client/shop/payment/payment-webhook-router.js";
+app.use("/api/client/hookie-hookie/", paymentWebhookRouter);
+
 // middleware
 import eventCodeMiddleware from "#middlewares/event-code-middleware.js";
 app.use(eventCodeMiddleware());
@@ -86,7 +92,7 @@ app.use("/api/client/orders/", cOrdersRouter);
 
 // обработчик ошибок
 import errorMiddleware from "./middlewares/error-middleware.js";
-import { ordersService } from "#modules/client/shop/orders-service.js";
+import { startSchedulers } from "#services/scheduler-service.js";
 
 app.use(errorMiddleware);
 //
@@ -101,9 +107,7 @@ const start = async () => {
     logger.info("Запуск сервера...");
     app.listen(PORT, () => logger.info(`🚀 Сервер запущен на порту ${PORT}`));
 
-    // Запуск проверки всех ожидающих заказов раз в час
-    ordersService.waitAllPendingOrders();
-    setInterval(() => ordersService.waitAllPendingOrders(), 60 * 60 * 1000);
+    startSchedulers();
   } catch (e) {
     logger.error(e);
   }

+ 39 - 32
src/modules/client/shop/c-orders-controller.ts

@@ -5,12 +5,11 @@ 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";
+import { paymentService } from "./payment/payment-service.js";
 
 class ClientOrdersController {
   async checkout(req: Request, res: Response) {
@@ -53,12 +52,6 @@ class ClientOrdersController {
       });
 
       // создаём оплату
-
-      // Инициализация провайдера
-      const yookassaProvider = new YooKassaProvider();
-      // Инициализация сервиса с конкретным провайдером
-      const paymentService = new PaymentService(yookassaProvider);
-
       const amount = {
         value: order.totalAmount.toFixed(2),
         currency: order.currencyCode,
@@ -101,9 +94,6 @@ class ClientOrdersController {
       throw new Error("Созданный заказ не найден");
     }
 
-    // асинхронное ожидание оплаты
-    ordersService.waitPandingOrder(orderId);
-
     RouterUtils.validAndSendResponse(api.client.shop.POST_Checkout.res, res, {
       code: "success",
       order: order,
@@ -162,11 +152,28 @@ class ClientOrdersController {
     });
   }
 
-  // FIXME: Expected string, received null
   async cancelOrder(req: Request, res: Response) {
     const { orderId } = api.client.shop.POST_CancelOrder.req.params.parse(
       req.params,
     );
+    const user = sessionService.getUserFromReq(req);
+
+    const order = await ordersService.getOrder({ orderId });
+
+    if (!order) {
+      throw ApiError.BadRequest("orderNotFound", "Заказ не найден");
+    }
+
+    if (order.userId !== user.userId) {
+      throw ApiError.ForbiddenError();
+    }
+
+    if (order.status !== "PENDING_PAYMENT") {
+      throw ApiError.BadRequest(
+        "orderNotPending",
+        "Заказ не в ожидании оплаты",
+      );
+    }
 
     await ordersService.cancelOrder(orderId);
 
@@ -179,26 +186,26 @@ class ClientOrdersController {
     );
   }
 
-  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,
-      },
-    });
-  }
+  // 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();

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

@@ -14,11 +14,11 @@ router.post(
   "/checkout",
   RouterUtils.asyncHandler(clientOrdersController.checkout),
 );
-router.delete(
-  "/:orderId",
+// router.get(
+//   "/:orderId/payment",
+//   RouterUtils.asyncHandler(clientOrdersController.getPayment),
+// );
+router.post(
+  "/:orderId/cancel",
   RouterUtils.asyncHandler(clientOrdersController.cancelOrder),
 );
-router.get(
-  "/:orderId/payment",
-  RouterUtils.asyncHandler(clientOrdersController.getPayment),
-);

+ 285 - 126
src/modules/client/shop/orders-service.ts

@@ -7,12 +7,10 @@ import { z } from "zod";
 import { DbSchema } from "#db/db-schema.js";
 import { generateRandomNumber } from "#utils/other-utils.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";
 import { CartShema, OrderShema } from "#api/v_0.1.0/types/shop-types.js";
-import { UnexpectedError } from "#exceptions/unexpected-errors.js";
+import { paymentService } from "./payment/payment-service.js";
 
 type OrderUserData = Record<string, SerializableValue> & {
   firstName: string;
@@ -146,7 +144,7 @@ class OrdersService {
 
   async getOrder(
     order: { orderId: string } | { orderNumber: string },
-  ): Promise<z.infer<typeof OrderShema>> {
+  ): Promise<z.infer<typeof OrderShema> | null> {
     const orderData = await selPool.maybeOne(sql.type(
       z.object({
         orderId: DbSchema.shop.orders.orderId,
@@ -197,13 +195,20 @@ class OrdersService {
         ${"orderId" in order ? sql.fragment`order_id = ${order.orderId}` : sql.fragment`order_number = ${order.orderNumber}`}
     `);
 
-    return OrderShema.parse(orderData);
+    return OrderShema.nullable().parse(orderData);
   }
 
   async cancelOrder(orderId: string) {
-    await updPool.query(sql.unsafe`
-      update shop.orders set status = 'CANCELLED' where order_id = ${orderId}
-    `);
+    await updPool.transaction(async (tr) => {
+      const payment = await this.getLastPaymentByOrderId(orderId);
+      if (!payment) return;
+      if (payment.status !== "PENDING") return;
+
+      await tr.query(sql.unsafe`
+        update shop.orders set status = 'CANCELLED' where order_id = ${orderId}
+      `);
+      await this.returnToCart({ orderId, tr });
+    });
   }
 
   async getLastPaymentByOrderId(orderId: string) {
@@ -248,104 +253,230 @@ class OrdersService {
     `);
   }
 
-  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");
+  // 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,
+  //   );
+  //   await updPool.transaction(async (tr) => {
+  //     if (status === "succeeded") {
+  //       // перепроверяем актуальность заказа
+  //       const actualOrder = await this.getOrder({ orderId });
+  //       if (!actualOrder) {
+  //         logger.error("Order not found");
+  //         return;
+  //       }
+
+  //       if (actualOrder.status === "CANCELLED") {
+  //         // возврат если оплатили отмененный заказ
+  //         if (!payment.externalTransactionId) {
+  //           logger.error("External transaction ID not found");
+  //           return;
+  //         }
+  //         await paymentService.refundPayment({
+  //           paymentId: payment.paymentId,
+  //           externalTransactionId: payment.externalTransactionId,
+  //           amount: {
+  //             value: payment.amount.toFixed(2),
+  //             currency: payment.currencyCode,
+  //           },
+  //         });
+  //         return;
+  //       }
+
+  //       if (actualOrder.status !== "PENDING_PAYMENT") {
+  //         logger.error("Order status is not PENDING_PAYMENT");
+  //         return;
+  //       }
+
+  //       await tr.query(sql.unsafe`
+  //         update shop.orders
+  //         set status = 'PAID'
+  //         where order_id = ${orderId}
+  //       `);
+
+  //       await tr.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 tr.query(sql.unsafe`
+  //         delete from shop.cart_items where order_id = ${orderId}
+  //       `);
+  //     }
+
+  //     if (status === "canceled") {
+  //       await tr.query(sql.unsafe`
+  //         update shop.orders
+  //         set status = 'CANCELLED'
+  //         where order_id = ${orderId}
+  //       `);
+
+  //       await tr.query(sql.unsafe`
+  //         update shop.order_items
+  //         set status = 'CANCELLED'
+  //         where order_id = ${orderId}
+  //       `);
+
+  //       // возвращаем в корзину
+  //       await this.returnToCart({ orderId, tr });
+  //     }
+
+  //     if (status === "failed") {
+  //       await tr.query(sql.unsafe`
+  //         update shop.orders
+  //         set status = 'FAILED'
+  //         where order_id = ${orderId}
+  //       `);
+
+  //       // возвращаем в корзину
+  //       await tr.query(sql.unsafe`
+  //         update shop.cart_items set order_id = null where order_id = ${orderId}
+  //       `);
+  //     }
+  //   });
+  // }
+
+  // Новая функция, которая обрабатывает успешную оплату
+  async processPaidOrder(tr: DatabaseTransactionConnection, orderId: string) {
+    logger.info(`Обработка успешной оплаты для заказа ${orderId}`);
+
+    const order = await this.getOrder({ orderId }); // Получаем актуальные данные заказа
+    if (!order) {
+      logger.error(`[processPaidOrder] Заказ ${orderId} не найден.`);
       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);
+    // Проверка, чтобы не обработать уже отмененный заказ
+    if (order.status === "CANCELLED") {
+      logger.warn(
+        `Попытка обработать оплату для уже отмененного заказа ${orderId}. Инициализация автоматического возврата...`,
+      );
+      const payment = await this.getLastPaymentByOrderId(orderId);
+      if (payment && payment.externalTransactionId) {
+        if (!payment) {
+          // Это критическая ситуация, прерываем транзакцию
+          throw new Error(
+            `Не найден платеж для возврата по отмененному заказу ${orderId}`,
+          );
         }
+
+        await paymentService.initiateFullPaymentRefund(
+          tr,
+          payment.paymentId,
+          "Автоматический возврат: оплата по отмененному заказу.",
+        );
       }
+      // Прекращаем дальнейшую обработку этого заказа
+      return;
+    }
 
-      // удаляем из корзины
-      await updPool.query(sql.unsafe`
-          delete from shop.cart_items where order_id = ${orderId}
-        `);
+    if (order.status !== "PENDING_PAYMENT") {
+      logger.warn(
+        `[processPaidOrder] Заказ ${orderId} уже в статусе ${order.status}. Обработка пропущена.`,
+      );
+      return;
     }
 
-    if (status === "canceled") {
-      await updPool.query(sql.unsafe`
-          update shop.orders
-          set status = 'CANCELLED'
-          where order_id = ${orderId}
-        `);
+    await tr.query(sql.unsafe`
+        UPDATE shop.orders SET status = 'PAID' WHERE order_id = ${orderId}
+      `);
+    await tr.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");
+        // TODO: для одной регистрации проверяется несколько раз
+        await cActService.updateActRegPaymentStatus({
+          tr,
+          activityRegId: item.activityRegId,
+        });
+      }
+    }
 
-      await updPool.query(sql.unsafe`
-          update shop.order_items
-          set status = 'CANCELLED'
-          where order_id = ${orderId}
-        `);
+    // Удаляем из корзины
+    await tr.query(sql.unsafe`
+        DELETE FROM shop.cart_items WHERE order_id = ${orderId}
+      `);
+  }
 
-      // возвращаем в корзину
-      await updPool.query(sql.unsafe`
-          update shop.cart_items set order_id = null where order_id = ${orderId}
-        `);
-    }
+  // Новая функция для обработки отмены/неудачи
+  async processCanceledOrder(
+    tr: DatabaseTransactionConnection,
+    orderId: string,
+  ) {
+    logger.info(`Обработка отмененного платежа для заказа ${orderId}`);
 
-    if (status === "failed") {
-      await updPool.query(sql.unsafe`
-          update shop.orders
-          set status = 'FAILED'
-          where order_id = ${orderId}
-        `);
+    await tr.query(sql.unsafe`
+      UPDATE shop.orders SET status = 'CANCELLED' WHERE order_id = ${orderId}
+    `);
+    await tr.query(sql.unsafe`
+      UPDATE shop.order_items SET status = 'CANCELLED' WHERE order_id = ${orderId}
+    `);
+    await this.returnToCart({ orderId, tr });
+  }
 
-      // возвращаем в корзину
-      await updPool.query(sql.unsafe`
+  async returnToCart({
+    orderId,
+    tr,
+  }: {
+    orderId: string;
+    tr: DatabaseTransactionConnection;
+  }) {
+    await tr.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 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): Promise<z.infer<typeof OrderShema>[]> {
     const orders = await selPool.any(sql.type(
@@ -406,22 +537,15 @@ class OrdersService {
         orderId: DbSchema.shop.orderItems.orderId,
         productId: DbSchema.shop.orderItems.productId,
         unitPrice: DbSchema.shop.orderItems.unitPrice,
-        paymentId: DbSchema.shop.payments.paymentId,
-        paymentExternalTransactionId:
-          DbSchema.shop.payments.externalTransactionId,
       }),
     )`
       select
         oi.order_item_id "orderItemId",
         oi.order_id "orderId",
         oi.product_id "productId",
-        oi.unit_price::float "unitPrice",
-        p.payment_id "paymentId",
-        p.external_transaction_id "paymentExternalTransactionId"
+        oi.unit_price::float "unitPrice"
       from
         shop.order_items oi
-      left join shop.payments p on
-        p.order_id = oi.order_id  
       where
         oi.order_item_id = ${orderItemId} and
         oi.status = 'PAID'
@@ -432,40 +556,75 @@ class OrdersService {
     const orderItem = await this.getOrderItem(orderItemId);
 
     if (!orderItem) {
-      throw new Error("Order item not found");
+      throw new Error(`Order item ${orderItemId} not found`);
     }
-
-    if (!orderItem.paymentExternalTransactionId) {
-      throw new Error("Payment external transaction id not found");
+    const payment = await this.getLastPaymentByOrderId(orderItem.orderId);
+    if (!payment) {
+      throw new Error(`Payment by order ${orderItem.orderId} not found`);
     }
 
-    // Инициализация провайдера
-    const yookassaProvider = new YooKassaProvider();
-    // Инициализация сервиса с конкретным провайдером
-    const paymentService = new PaymentService(yookassaProvider);
-
-    const refund = await paymentService.refundPayment({
-      paymentId: orderItem.paymentExternalTransactionId,
-      amount: {
-        value: orderItem.unitPrice.toFixed(2),
-        currency: "RUB",
-      },
-    });
+    // Сумма к возврату
+    const amountToRefund = orderItem.unitPrice;
+    const refundIdempotencyKey = uuidv7();
 
-    if (refund.status !== "succeeded") {
-      logger.error("Payment refund failed", refund);
-      throw new UnexpectedError(
-        500,
-        "Payment refund failed",
-        "Payment refund failed",
+    // Создаем запись о возврате в нашей БД ДО запроса к ЮKassa
+    const { refundId } = await updPool.one(sql.type(
+      z.object({ refundId: z.string().uuid() }),
+    )`
+ INSERT INTO shop.refunds (
+     payment_id,
+     order_item_id,
+     external_refund_id, -- Используем idempotency key как временный ID, который потом заменит ЮKassa
+     amount,
+     currency_code,
+     status,
+     reason
+ ) VALUES (
+     ${payment.paymentId},
+     ${orderItem.orderItemId},
+     ${refundIdempotencyKey}, -- ВАЖНО: это временный ID
+     ${amountToRefund},
+     'RUB',
+     'pending',
+     'Возврат по запросу пользователя'
+ )
+ RETURNING refund_id as "refundId"
+`);
+
+    try {
+      const refundResponse = await paymentService.refundPayment({
+        paymentId: payment.paymentId,
+        amount: {
+          value: amountToRefund.toFixed(2),
+          currency: "RUB",
+        },
+      });
+
+      // После успешного создания возврата в ЮKassa, обновляем нашу запись
+      // настоящим external_refund_id
+      await updPool.query(sql.unsafe`
+      UPDATE shop.refunds
+      SET
+          external_refund_id = ${refundResponse.id}, -- ID от ЮKassa
+          provider_details = ${sql.jsonb(refundResponse.providerSpecificDetails || {})}
+      WHERE refund_id = ${refundId}
+  `);
+
+      // Мы НЕ МЕНЯЕМ статус на 'succeeded' здесь.
+      // Мы ждем вебхук "refund.succeeded", который будет источником правды.
+      // Это делает систему устойчивой к сбоям.
+
+      logger.info(
+        `Запрос на возврат ${refundResponse.id} для позиции ${orderItemId} успешно создан. Ожидаем подтверждения от провайдера.`,
       );
+    } catch (error) {
+      // Если запрос к ЮKassa провалился, удаляем нашу запись о возврате
+      await updPool.query(sql.unsafe`
+      DELETE FROM shop.refunds WHERE refund_id = ${refundId}
+  `);
+      logger.error("Создание возврата в ЮKassa не удалось", error);
+      throw error; // Пробрасываем ошибку дальше
     }
-
-    await updPool.query(sql.unsafe`
-          update shop.order_items
-          set status = 'REFUNDED'
-          where order_item_id = ${orderItem.orderItemId}
-        `);
   }
 }
 

+ 38 - 0
src/modules/client/shop/payment/payment-provider-factory.ts

@@ -0,0 +1,38 @@
+import { IPaymentProvider } from "#modules/client/shop/payment/payment-provider-types.js";
+import { YooKassaProvider } from "#modules/client/shop/payment/yookassa-provider.js";
+// import { SberProvider } from "#modules/client/shop/payment/sber-provider.js"; // Будущий провайдер
+import { config } from "#config/config.js";
+
+export type BankName = "YOOKASSA" | "SBERBANK"; // Тип для имен поддерживаемых банков
+
+class PaymentProviderFactory {
+  private providers: Map<BankName, IPaymentProvider> = new Map();
+
+  constructor() {
+    // Инициализируем всех доступных провайдеров при старте
+    // if (config.YOOOKASSA_ENABLED) {
+    this.providers.set(
+      "YOOKASSA",
+      new YooKassaProvider({
+        shopId: config.YOOOKASSA_SHOP_ID,
+        secretKey: config.YOOOKASSA_SECRET_KEY,
+      }),
+    );
+    // }
+    // if (config.SBERBANK_ENABLED) {
+    //   this.providers.set("SBERBANK", new SberProvider({ /* ... */ }));
+    // }
+  }
+
+  getProvider(bankName: BankName): IPaymentProvider {
+    const provider = this.providers.get(bankName);
+    if (!provider) {
+      throw new Error(
+        `Payment provider for ${bankName} is not configured or supported.`,
+      );
+    }
+    return provider;
+  }
+}
+
+export const paymentProviderFactory = new PaymentProviderFactory();

+ 12 - 21
src/modules/client/shop/payment/payment-provider-types.ts

@@ -74,7 +74,8 @@ export type PaymentStatusResponse = z.infer<typeof PaymentStatusResponseSchema>;
 
 // --- Общие параметры для создания возврата ---
 export const CreateRefundParamsSchema = z.object({
-  paymentId: z.string(), // ID оригинального платежа
+  paymentId: z.string(), // ID внутреннего патежа
+  externalTransactionId: z.string(), // ID оригинального платежа
   amount: z.object({
     value: z
       .string()
@@ -92,7 +93,7 @@ export type CreateRefundParams = z.infer<typeof CreateRefundParamsSchema>;
 // --- Общий ответ при создании возврата ---
 export const RefundResponseSchema = z.object({
   id: z.string(), // ID возврата
-  paymentId: z.string(),
+  externalTransactionId: z.string(),
   status: z.string(), // Статус возврата (e.g., 'pending', 'succeeded')
   amount: z.object({
     value: z.string(),
@@ -104,19 +105,9 @@ export const RefundResponseSchema = z.object({
 });
 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 const RefundStatusResponseSchema = RefundResponseSchema; // На данный момент она идентична обычному ответу
+export type RefundStatusResponse = z.infer<typeof RefundStatusResponseSchema>;
 
 // --- Интерфейс платежного провайдера ---
 export interface IPaymentProvider {
@@ -124,14 +115,14 @@ export interface IPaymentProvider {
     params: CreatePaymentParams,
     idempotencyKey?: string,
   ): Promise<PaymentResponse>;
-  getPaymentStatus(paymentId: string): Promise<PaymentStatusResponse>;
+  getPaymentStatus(externalTransactionId: {
+    externalTransactionId: string;
+  }): Promise<PaymentStatusResponse>;
   createRefund(
     params: CreateRefundParams,
     idempotencyKey?: string,
   ): Promise<RefundResponse>;
-  cancelPayment(
-    params: CancelPaymentParams,
-    idempotencyKey?: string,
-  ): Promise<CancelPaymentResponse>;
-  // Можно добавить getRefundStatus(refundId: string): Promise<RefundStatusResponse>;
+  getRefundStatus(params: {
+    externalRefundId: string;
+  }): Promise<RefundStatusResponse>;
 }

+ 498 - 163
src/modules/client/shop/payment/payment-service.ts

@@ -1,40 +1,35 @@
 import { selPool, updPool } from "#db/db.js";
 import { DatabaseTransactionConnection, sql } from "slonik";
 import {
-  IPaymentProvider,
   CreatePaymentParams,
-  CreateRefundParams,
   RefundResponse,
   PaymentStatusResponse,
-  CancelPaymentParams,
-  CancelPaymentResponse,
+  RefundStatusResponse,
 } 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 { ordersService } from "../orders-service.js";
 import { PaymentProviderError } from "./shop-errors.js";
+import {
+  BankName,
+  paymentProviderFactory,
+} from "./payment-provider-factory.js";
+import { ApiError } from "#exceptions/api-error.js";
 
-export class PaymentService {
-  private provider: IPaymentProvider;
-
-  constructor(provider: IPaymentProvider) {
-    this.provider = provider;
-  }
-
+class PaymentService {
   async createPayment(
     tr: DatabaseTransactionConnection,
     params: CreatePaymentParams,
   ) {
+    const provider = paymentProviderFactory.getProvider(params.bank);
     const paymentId = uuidv7();
 
     logger.info(`Создание платежа: ${paymentId}...`);
 
-    const paymentResponse = await this.provider.createPayment(
-      params,
-      paymentId,
-    );
+    const paymentResponse = await provider.createPayment(params, paymentId);
     const payment = await tr.one(sql.type(
       z.object({
         paymentId: DbSchema.shop.payments.paymentId,
@@ -106,206 +101,546 @@ export class PaymentService {
     return payment;
   }
 
-  async getPaymentStatus(paymentId: string): Promise<PaymentStatusResponse> {
-    return this.provider.getPaymentStatus(paymentId);
+  async getPaymentByExternalId(externalTransactionId: string) {
+    // Этот метод нужен для вебхука, чтобы найти наш внутренний платеж
+    return selPool.maybeOne(sql.type(
+      z.object({
+        paymentId: DbSchema.shop.payments.paymentId,
+        orderId: DbSchema.shop.payments.orderId,
+        status: DbSchema.shop.payments.status,
+        amount: DbSchema.shop.payments.amount,
+        currencyCode: DbSchema.shop.payments.currencyCode,
+      }),
+    )`
+      SELECT
+        payment_id AS "paymentId",
+        order_id AS "orderId",
+        status,
+        amount::float AS "amount",
+        currency_code AS "currencyCode"
+      FROM shop.payments
+      WHERE external_transaction_id = ${externalTransactionId}
+    `);
   }
 
-  async refundPayment(params: CreateRefundParams): Promise<RefundResponse> {
-    // Аналогично, можно добавить логику
-    const idempotencyKey = uuidv7();
-    console.log(
-      `Creating refund for payment ${params.paymentId} with idempotency key: ${idempotencyKey}`,
-    );
-
-    const refund = await this.provider.createRefund(params, idempotencyKey);
+  async processSuccessfulPayment(
+    tr: DatabaseTransactionConnection,
+    paymentId: string,
+    orderId: string,
+  ) {
+    // 1. Обновляем статус платежа
+    await tr.query(sql.unsafe`
+      UPDATE shop.payments
+      SET status = 'SUCCEEDED'
+      WHERE payment_id = ${paymentId}
+    `);
 
-    if (refund.status !== "succeeded") {
-      throw new PaymentProviderError(
-        `Refund failed for payment ${params.paymentId}`,
-        refund,
-      );
-    }
+    // 2. Вызываем логику обработки заказа из OrdersService
+    await ordersService.processPaidOrder(tr, orderId);
+  }
 
-    await updPool.query(sql.unsafe`
-      update shop.payments
-      set status = 'REFUNDED'
-      where payment_id = ${params.paymentId}
+  async processFailedOrCanceledPayment(
+    tr: DatabaseTransactionConnection,
+    paymentId: string,
+    orderId: string,
+    status: "CANCELED" | "FAILED",
+  ) {
+    // 1. Обновляем статус платежа
+    await tr.query(sql.unsafe`
+      UPDATE shop.payments
+      SET status = ${status}
+      WHERE payment_id = ${paymentId}
     `);
 
-    return refund;
+    // 2. Вызываем логику обработки заказа из OrdersService
+    await ordersService.processCanceledOrder(tr, orderId);
   }
 
-  // Новая функция для отмены платежа
-  async cancelPayment(
-    // tr: DatabaseTransactionConnection,
-    paymentIdToCancel: string,
-  ): Promise<CancelPaymentResponse> {
-    const idempotencyKey = uuidv7();
-    logger.info(
-      `Попытка отмены платежа: ${paymentIdToCancel} с ключом идемпотентности: ${idempotencyKey}`,
-    );
+  async getPaymentStatus({
+    bank,
+    externalTransactionId,
+  }: {
+    bank: BankName;
+    externalTransactionId: string;
+  }): Promise<PaymentStatusResponse> {
+    const provider = paymentProviderFactory.getProvider(bank);
 
-    // Получаем order_id перед отменой, чтобы обновить заказ
-    const paymentDetails = await selPool.maybeOne(sql.type(
+    return provider.getPaymentStatus({ externalTransactionId });
+  }
+
+  async getPayment(paymentId: string) {
+    return selPool.maybeOne(sql.type(
       z.object({
+        paymentId: DbSchema.shop.payments.paymentId,
         orderId: DbSchema.shop.payments.orderId,
         status: DbSchema.shop.payments.status,
+        amount: DbSchema.shop.payments.amount,
+        currencyCode: DbSchema.shop.payments.currencyCode,
+        bank: DbSchema.shop.payments.bank,
+        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 order_id as "orderId", status from shop.payments where payment_id = ${paymentIdToCancel}
-    `);
+      select 
+        payment_id as "paymentId",
+        order_id as "orderId",
+        status,
+        amount::float as "amount",
+        currency_code as "currencyCode",
+        bank,
+        external_transaction_id as "externalTransactionId",
+        payment_gateway_details as "paymentGatewayDetails",
+        created_at as "createdAt",
+        updated_at as "updatedAt",
+        confirmation
+      from shop.payments
+      where payment_id = ${paymentId}
+      `);
+  }
 
-    if (!paymentDetails) {
-      logger.error(`Платеж ${paymentIdToCancel} не найден для отмены.`);
-      throw new PaymentProviderError(
-        `Payment with ID ${paymentIdToCancel} not found.`,
-        { paymentId: paymentIdToCancel },
-      );
+  async refundPayment(refundParams: {
+    paymentId: string;
+    amount: {
+      value: string;
+      currency: string;
+    };
+    description?: string | undefined;
+  }): Promise<RefundResponse> {
+    const payment = await this.getPayment(refundParams.paymentId);
+    if (!payment) {
+      throw ApiError.BadRequest("paymentNotFound", "Не найден платеж");
     }
+    const provider = paymentProviderFactory.getProvider(payment.bank);
 
-    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 idempotencyKey = uuidv7();
+    logger.info(
+      `Creating refund for payment externalTransactionId: ${payment.externalTransactionId} with idempotency key: ${idempotencyKey}`,
+    );
 
-    const cancelParams: CancelPaymentParams = { paymentId: paymentIdToCancel };
-    const cancelResponse = await this.provider.cancelPayment(
-      cancelParams,
+    const refund = await provider.createRefund(
+      {
+        paymentId: refundParams.paymentId,
+        amount: refundParams.amount,
+        externalTransactionId: payment.externalTransactionId,
+        description: refundParams.description,
+      },
       idempotencyKey,
     );
+    logger.info(`Refund created: ${refund.id}`);
+
+    return refund;
+  }
+
+  /**
+   * Находит все "зависшие" платежи в статусе PENDING.
+   * "Зависшие" - это те, что были созданы некоторое время назад,
+   * но их статус так и не обновился через вебхук.
+   */
+  async getAllPendingPayments(options: { olderThanMinutes: number }) {
+    const cutoffDate = dayjs()
+      .subtract(options.olderThanMinutes, "minute")
+      .toISOString();
+
+    return selPool.any(sql.type(
+      z.object({
+        paymentId: DbSchema.shop.payments.paymentId,
+        orderId: DbSchema.shop.payments.orderId,
+        externalTransactionId: DbSchema.shop.payments.externalTransactionId,
+        bank: DbSchema.shop.payments.bank,
+      }),
+    )`
+      SELECT
+        payment_id AS "paymentId",
+        order_id AS "orderId",
+        external_transaction_id AS "externalTransactionId",
+        bank
+      FROM shop.payments
+      WHERE
+        status = 'PENDING' AND
+        created_at < ${cutoffDate}
+    `);
+  }
+
+  /**
+   * Процесс сверки, который запускается по расписанию (cron).
+   * Он опрашивает статус зависших платежей у провайдера
+   * на случай, если вебхук не дошел.
+   */
+  async reconcilePendingPayments() {
+    logger.info("Запуск сверки зависших платежей...");
+
+    // Ищем платежи, которые висят в PENDING более 15 минут
+    const pendingPayments = await this.getAllPendingPayments({
+      olderThanMinutes: 15,
+    });
+
+    if (pendingPayments.length === 0) {
+      logger.info("Зависших платежей не найдено.");
+      return;
+    }
 
     logger.info(
-      `Ответ от провайдера по отмене платежа ${paymentIdToCancel}: статус ${cancelResponse.status}`,
+      `Найдено ${pendingPayments.length} зависших платежей для сверки.`,
     );
 
-    // Обновляем статус в нашей БД, если провайдер подтвердил отмену или платеж уже был отменен у провайдера
-    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' -- Обновляем только если заказ ожидал оплату
-        `);
+    for (const payment of pendingPayments) {
+      try {
         logger.info(
-          `Статус заказа ${paymentDetails.orderId} обновлен на CANCELED в БД.`,
+          `Сверка статуса для платежа ${payment.paymentId} (external: ${payment.externalTransactionId})...`,
         );
+
+        // 1. Получаем актуальный статус от провайдера
+        const provider = paymentProviderFactory.getProvider(payment.bank);
+
+        const paymentStatus = await provider.getPaymentStatus({
+          externalTransactionId: payment.externalTransactionId,
+        });
+
+        // 2. Обрабатываем статус в транзакции
+        await updPool.transaction(async (tr) => {
+          if (
+            paymentStatus.status === "succeeded" ||
+            paymentStatus.status === "waiting_for_capture"
+          ) {
+            logger.info(
+              `[RECONCILE] Статус платежа ${payment.paymentId} - SUCCEEDED. Обновляем...`,
+            );
+            await this.processSuccessfulPayment(
+              tr,
+              payment.paymentId,
+              payment.orderId,
+            );
+          } else if (paymentStatus.status === "canceled") {
+            logger.info(
+              `[RECONCILE] Статус платежа ${payment.paymentId} - CANCELED. Обновляем...`,
+            );
+            await this.processFailedOrCanceledPayment(
+              tr,
+              payment.paymentId,
+              payment.orderId,
+              "CANCELED",
+            );
+          } else if (paymentStatus.status === "pending") {
+            // Если статус все еще pending, проверяем, не истек ли срок жизни платежа
+            const paymentDetails = await selPool.maybeOne(
+              sql.type(
+                z.object({
+                  paymentDueDate: DbSchema.shop.orders.paymentDueDate,
+                }),
+              )`SELECT o.payment_due_date "paymentDueDate" FROM shop.payments p join shop.orders o on p.order_id = o.order_id WHERE payment_id = ${payment.paymentId}`,
+            );
+            if (!paymentDetails) {
+              logger.error(
+                `[RECONCILE] Платеж ${payment.paymentId} не найден. Пропускаем.`,
+              );
+              return;
+            }
+
+            if (dayjs().isAfter(dayjs(paymentDetails.paymentDueDate))) {
+              logger.info(
+                `[RECONCILE] Платеж ${payment.paymentId} просрочен. Отменяем...`,
+              );
+              await this.processFailedOrCanceledPayment(
+                tr,
+                payment.paymentId,
+                payment.orderId,
+                "CANCELED",
+              );
+            } else {
+              logger.info(
+                `[RECONCILE] Платеж ${payment.paymentId} все еще в PENDING у провайдера. Пропускаем.`,
+              );
+            }
+          }
+        });
+      } catch (error) {
+        logger.error(`Ошибка при сверке платежа ${payment.paymentId}:`, error);
+        // Продолжаем цикл, чтобы ошибка с одним платежом не остановила весь процесс
       }
-    } 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;
+    logger.info("Сверка зависших платежей завершена.");
   }
 
-  async waitPayment(paymentId: string, endDate: string) {
-    const payment = await selPool.maybeOne(sql.type(
+  /**
+   * Инициирует полный возврат для указанного платежа.
+   * Создает запись о возврате в БД и отправляет запрос провайдеру.
+   * @param tr - Активная транзакция базы данных.
+   * @param paymentId - Внутренний ID платежа для возврата.
+   * @param reason - Причина возврата.
+   */
+  async initiateFullPaymentRefund(
+    tr: DatabaseTransactionConnection,
+    paymentId: string,
+    reason: string,
+  ) {
+    logger.info(`Инициирование полного возврата для платежа ${paymentId}...`);
+
+    // 1. Получаем детали платежа и блокируем строку для обновления (FOR UPDATE)
+    const payment = await tr.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,
+        amount: DbSchema.shop.payments.amount,
+        refundedAmount: DbSchema.shop.payments.refundedAmount,
+        currencyCode: DbSchema.shop.payments.currencyCode,
+        status: DbSchema.shop.payments.status,
+        bank: DbSchema.shop.payments.bank,
       }),
     )`
-      select payment_id as "paymentId",
-      order_id as "orderId",
-      status,
-      external_transaction_id as "externalTransactionId"
-      from shop.payments
-      where payment_id = ${paymentId}
-    `);
+        SELECT
+          payment_id AS "paymentId",
+          external_transaction_id AS "externalTransactionId",
+          amount::float AS "amount",
+          refunded_amount::float AS "refundedAmount",
+          currency_code AS "currencyCode",
+          status,
+          bank
+        FROM shop.payments
+        WHERE payment_id = ${paymentId}
+        FOR UPDATE
+      `);
 
     if (!payment) {
-      logger.error(`Платеж ${paymentId} не найден.`);
       throw new PaymentProviderError(
-        `Payment with ID ${paymentId} not found.`,
-        { paymentId },
+        `Платеж ${paymentId} не найден для возврата.`,
       );
     }
 
-    if (!payment.externalTransactionId) {
-      logger.error(`externalTransactionId платежа ${paymentId} не найден.`);
+    if (payment.status !== "SUCCEEDED") {
       throw new PaymentProviderError(
-        `externalTransactionId платежа ${paymentId} не найден.`,
-        { paymentId },
+        `Невозможно вернуть платеж ${paymentId} в статусе ${payment.status}.`,
       );
     }
 
-    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}
-        `);
+    const amountToRefund = payment.amount - (payment.refundedAmount ?? 0);
 
-        return "succeeded";
-      }
+    if (amountToRefund <= 0) {
+      logger.warn(`Платеж ${paymentId} уже полностью возвращен. Пропускаем.`);
+      return;
+    }
 
-      if (paymentStatus.status === "canceled") {
-        logger.info(`Платеж отменен провайдером: ${paymentId}`);
-        await updPool.query(sql.unsafe`
-          update shop.payments
-          set status = 'CANCELED'
-          where payment_id = ${paymentId}
+    // 2. Создаем запись о возврате в нашей БД
+    const idempotencyKey = uuidv7();
+    const { refundId } = await tr.one(sql.type(
+      z.object({ refundId: z.string().uuid() }),
+    )`
+        INSERT INTO shop.refunds (payment_id, external_refund_id, amount, currency_code, status, reason)
+        VALUES (
+          ${payment.paymentId},
+          ${idempotencyKey}, -- Временный ID до получения ответа от провайдера
+          ${amountToRefund},
+          ${payment.currencyCode},
+          'pending',
+          ${reason}
+        )
+        RETURNING refund_id as "refundId"
+      `);
+
+    // 3. Отправляем запрос провайдеру
+    try {
+      const provider = paymentProviderFactory.getProvider(payment.bank);
+
+      const refundResponse = await provider.createRefund(
+        {
+          externalTransactionId: payment.externalTransactionId,
+          amount: {
+            value: amountToRefund.toFixed(2),
+            currency: payment.currencyCode,
+          },
+          paymentId: payment.paymentId,
+          description: reason,
+        },
+        idempotencyKey,
+      );
+
+      // 4. Обновляем нашу запись о возврате настоящим ID от провайдера
+      await tr.query(sql.unsafe`
+          UPDATE shop.refunds
+          SET
+            external_refund_id = ${refundResponse.id},
+            provider_details = ${sql.jsonb(refundResponse.providerSpecificDetails || {})}
+          WHERE refund_id = ${refundId}
         `);
-        return "canceled";
-      }
 
-      if (paymentStatus.status === "pending") {
-        await new Promise((resolve) => setTimeout(resolve, 5000));
-        continue;
-      }
+      logger.info(
+        `Запрос на возврат ${refundResponse.id} для платежа ${payment.paymentId} успешно создан. Ожидаем подтверждения.`,
+      );
 
+      // Возвращаем ID созданной записи о возврате
+      return refundId;
+    } catch (error) {
+      // Если запрос к провайдеру не удался, транзакция откатится автоматически,
+      // и созданная запись в shop.refunds будет удалена.
+      // Нам нужно просто пробросить ошибку дальше.
       logger.error(
-        `Неизвестный статус платежа ${paymentId}: ${paymentStatus.status}`,
+        `Ошибка при создании возврата через провайдера для платежа ${payment.paymentId}:`,
+        error,
       );
-      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}
+      throw error;
+    }
+  }
+
+  /**
+   * Централизованный метод для обработки обновления статуса возврата.
+   * Может вызываться как из обработчика вебхуков, так и из сервиса сверки.
+   * @param tr - Активная транзакция базы данных.
+   * @param refundStatus - Данные о статусе возврата в общем формате.
+   */
+  public async processRefundStatusUpdate(
+    tr: DatabaseTransactionConnection,
+    refundStatus: RefundStatusResponse,
+  ) {
+    // 1. Находим наш внутренний возврат по external_refund_id и блокируем его.
+    // Это предотвращает гонку состояний, если вебхук и сверка сработают одновременно.
+    const refundInDb = await tr.maybeOne(sql.type(
+      z.object({
+        refundId: z.string().uuid(),
+        status: z.string(),
+      }),
+    )`
+        UPDATE shop.refunds
+        SET status = ${refundStatus.status} -- Предварительно обновляем статус
+        WHERE external_refund_id = ${refundStatus.id} AND status = 'pending'
+        RETURNING refund_id AS "refundId", status
     `);
-    return "canceled";
+
+    // Если запись не найдена или ее статус уже не 'pending', значит, она была обработана ранее.
+    if (!refundInDb) {
+      logger.warn(
+        `Возврат с external_id=${refundStatus.id} не найден в статусе 'pending' или уже обработан. Пропускаем.`,
+      );
+      return;
+    }
+
+    logger.info(
+      `Обработка обновления статуса для возврата ${refundInDb.refundId} -> ${refundStatus.status}`,
+    );
+
+    // 2. Если статус 'succeeded', обновляем связанные данные
+    if (refundStatus.status === "succeeded") {
+      const refundAmount = parseFloat(refundStatus.amount.value);
+
+      // Обновляем сумму возврата в родительском платеже
+      await tr.query(sql.unsafe`
+        UPDATE shop.payments
+        SET refunded_amount = refunded_amount + ${refundAmount}
+        WHERE payment_id = (SELECT payment_id FROM shop.refunds WHERE refund_id = ${refundInDb.refundId})
+      `);
+
+      // Ищем связанную позицию заказа
+      const { orderItemId } = await tr.one(sql.type(
+        z.object({ orderItemId: z.string().uuid().nullable() }),
+      )`
+          SELECT order_item_id as "orderItemId" FROM shop.refunds WHERE refund_id = ${refundInDb.refundId}
+      `);
+
+      if (orderItemId) {
+        await tr.query(sql.unsafe`
+            UPDATE shop.order_items
+            SET 
+              status = 'REFUNDED'
+            WHERE order_item_id = ${orderItemId}
+          `);
+
+        // TODO: проверить
+        // Здесь также можно добавить логику обновления статуса всего заказа, если нужно
+      }
+    }
+    // Если статус 'canceled', мы уже обновили его в первом запросе.
+    // Больше ничего делать не нужно, но можно добавить логирование или уведомления.
+  }
+
+  /**
+   * Находит все "зависшие" возвраты в статусе PENDING.
+   */
+  async getAllPendingRefunds(options: { olderThanMinutes: number }) {
+    const cutoffDate = dayjs()
+      .subtract(options.olderThanMinutes, "minute")
+      .toISOString();
+
+    return selPool.any(sql.type(
+      z.object({
+        refundId: DbSchema.shop.refunds.refundId,
+        externalRefundId: DbSchema.shop.refunds.externalRefundId,
+        bank: DbSchema.shop.payments.bank,
+      }),
+    )`
+    SELECT
+      r.refund_id AS "refundId",
+      r.external_refund_id AS "externalRefundId",
+      p.bank
+    FROM shop.refunds AS r
+    JOIN shop.payments AS p ON r.payment_id = p.payment_id
+    WHERE
+      r.status = 'pending' AND
+      r.created_at < ${cutoffDate}
+  `);
+  }
+
+  /**
+   * Процесс сверки возвратов, который запускается по расписанию (cron).
+   * Он опрашивает статус зависших возвратов у провайдера
+   * на случай, если вебхук не дошел.
+   */
+  async reconcilePendingRefunds() {
+    logger.info("Запуск сверки зависших возвратов...");
+
+    // Ищем возвраты, которые висят в 'pending' более 30 минут
+    const pendingRefunds = await this.getAllPendingRefunds({
+      olderThanMinutes: 30,
+    });
+
+    if (pendingRefunds.length === 0) {
+      logger.info("Зависших возвратов не найдено.");
+      return;
+    }
+
+    logger.info(
+      `Найдено ${pendingRefunds.length} зависших возвратов для сверки.`,
+    );
+
+    for (const refund of pendingRefunds) {
+      try {
+        logger.info(
+          `Сверка статуса для возврата ${refund.refundId} (external: ${refund.externalRefundId})...`,
+        );
+
+        // 1. Получаем нужного провайдера через фабрику
+        const provider = paymentProviderFactory.getProvider(refund.bank);
+
+        // 2. Получаем актуальный статус от провайдера
+        // (Обратите внимание: для консистентности API лучше, чтобы getRefundStatus тоже принимал объект)
+        const refundStatus = await provider.getRefundStatus({
+          externalRefundId: refund.externalRefundId,
+        });
+
+        // 3. Обрабатываем статус в транзакции, используя уже существующий метод!
+        // Это ключевой момент для избежания дублирования логики.
+        if (
+          refundStatus.status === "succeeded" ||
+          refundStatus.status === "canceled"
+        ) {
+          logger.info(
+            `[RECONCILE-REFUND] Статус возврата ${refund.refundId} - ${refundStatus.status}. Обновляем...`,
+          );
+          await updPool.transaction(async (tr) => {
+            await this.processRefundStatusUpdate(tr, refundStatus);
+          });
+        } else {
+          logger.info(
+            `[RECONCILE-REFUND] Возврат ${refund.refundId} все еще в PENDING у провайдера. Пропускаем.`,
+          );
+        }
+      } catch (error) {
+        logger.error(`Ошибка при сверке возврата ${refund.refundId}:`, error);
+        // Продолжаем цикл, чтобы ошибка с одним возвратом не остановила весь процесс
+      }
+    }
+    logger.info("Сверка зависших возвратов завершена.");
   }
 }
+
+export const paymentService = new PaymentService();

+ 22 - 0
src/modules/client/shop/payment/payment-webhook-controller.ts

@@ -0,0 +1,22 @@
+import { Request, Response } from "express";
+import { ApiError } from "#exceptions/api-error.js";
+import { logger } from "#plugins/logger.js";
+import { yookassaWebhookService } from "./yookassa-webhook-service.js";
+
+class YookassaWebhookController {
+  async handleYooKassa(req: Request, res: Response) {
+    const ip = req.ip;
+
+    if (!ip || !yookassaWebhookService.isYooKassaIp(ip)) {
+      logger.warn(`Попытка доступа к вебхуку с недоверенного IP: ${ip}`);
+      throw ApiError.ForbiddenError();
+    }
+
+    await yookassaWebhookService.handleYooKassaNotification(req.body);
+
+    // ЮKassa ждет ответ 200 OK.
+    res.status(200).send("OK");
+  }
+}
+
+export const yookassaWebhookController = new YookassaWebhookController();

+ 13 - 0
src/modules/client/shop/payment/payment-webhook-router.ts

@@ -0,0 +1,13 @@
+import { RouterUtils } from "#utils/router-utils.js";
+import express from "express";
+import { yookassaWebhookController } from "./payment-webhook-controller.js";
+
+const router = express.Router();
+export default router;
+
+// Важно: этот роут не должен требовать аутентификации пользователя
+router.post(
+  "/yookassa-webhook",
+  express.json(),
+  RouterUtils.asyncHandler(yookassaWebhookController.handleYooKassa),
+);

+ 37 - 57
src/modules/client/shop/payment/yookassa-provider.ts

@@ -7,8 +7,7 @@ import {
   CreateRefundParams,
   RefundResponse,
   PaymentStatusResponse,
-  CancelPaymentParams,
-  CancelPaymentResponse,
+  RefundStatusResponse,
 } from "./payment-provider-types.js";
 import {
   YooKassaCreatePaymentRequest,
@@ -31,10 +30,10 @@ export class YooKassaProvider implements IPaymentProvider {
   private readonly secretKey: string;
   private readonly apiUrl: string;
 
-  constructor() {
-    // TODO: вынести в таблицу
-    this.shopId = "1027499";
-    this.secretKey = "test_iTpukmMRAmujJSvL2WqoD-VNxp_bmvtjIkELGRsiUys";
+  constructor({ shopId, secretKey }: { shopId: string; secretKey: string }) {
+    this.shopId = shopId;
+    this.secretKey = secretKey;
+    // TODO: вынести в env?
     this.apiUrl = "https://api.yookassa.ru/v3";
 
     if (!this.shopId || !this.secretKey) {
@@ -219,57 +218,14 @@ export class YooKassaProvider implements IPaymentProvider {
     }
   }
 
-  // >>> НОВАЯ ФУНКЦИЯ ОТМЕНЫ ПЛАТЕЖА <<<
-  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> {
+  async getPaymentStatus({
+    externalTransactionId,
+  }: {
+    externalTransactionId: string;
+  }): Promise<PaymentStatusResponse> {
     try {
       const response = await this.axiosInstance.get<YooKassaPaymentResponse>(
-        `/payments/${paymentId}`,
+        `/payments/${externalTransactionId}`,
       );
       const parsedData = YooKassaPaymentResponseSchema.parse(response.data);
       return this.mapToGenericPaymentStatusResponse(parsedData);
@@ -288,7 +244,7 @@ export class YooKassaProvider implements IPaymentProvider {
     params: CreateRefundParams,
   ): YooKassaCreateRefundRequest {
     const ykRequest: YooKassaCreateRefundRequest = {
-      payment_id: params.paymentId,
+      payment_id: params.externalTransactionId,
       amount: {
         value: params.amount.value,
         currency: params.amount.currency.toUpperCase(),
@@ -306,7 +262,7 @@ export class YooKassaProvider implements IPaymentProvider {
   ): RefundResponse {
     return {
       id: ykResponse.id,
-      paymentId: ykResponse.payment_id,
+      externalTransactionId: ykResponse.payment_id,
       status: ykResponse.status,
       amount: {
         value: ykResponse.amount.value,
@@ -353,4 +309,28 @@ export class YooKassaProvider implements IPaymentProvider {
       this.handleError(error);
     }
   }
+
+  async getRefundStatus({
+    externalRefundId,
+  }: {
+    externalRefundId: string;
+  }): Promise<RefundStatusResponse> {
+    try {
+      const response = await this.axiosInstance.get<YooKassaRefundResponse>(
+        `/refunds/${externalRefundId}`,
+      );
+
+      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 getRefundStatus: ${error.message}`,
+          error.format(),
+        );
+      }
+      this.handleError(error);
+    }
+  }
 }

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

@@ -123,4 +123,28 @@ export const YooKassaErrorResponseSchema = z
     parameter: z.string().optional(), // e.g. "payment_token"
   })
   .passthrough();
+
 export type YooKassaErrorResponse = z.infer<typeof YooKassaErrorResponseSchema>;
+
+export const YooKassaNotificationObjectSchema =
+  YooKassaPaymentResponseSchema.extend({
+    // Уведомление содержит тот же объект платежа, но могут быть нюансы.
+    // На всякий случай, если в будущем появятся отличия, можно будет их тут описать.
+  });
+
+export const YooKassaWebhookNotificationSchema = z.object({
+  type: z.literal("notification"),
+  event: z.enum([
+    "payment.succeeded",
+    "payment.waiting_for_capture",
+    "payment.canceled",
+    "refund.succeeded",
+  ]),
+  object: z.union([
+    YooKassaPaymentResponseSchema, // для событий payment.*
+    YooKassaRefundResponseSchema, // для событий refund.*
+  ]),
+});
+export type YooKassaWebhookNotification = z.infer<
+  typeof YooKassaWebhookNotificationSchema
+>;

+ 202 - 0
src/modules/client/shop/payment/yookassa-webhook-service.ts

@@ -0,0 +1,202 @@
+import {
+  YooKassaPaymentResponseSchema,
+  YooKassaRefundResponseSchema,
+  YooKassaWebhookNotificationSchema,
+} from "./yookassa-types.js";
+import { logger } from "#plugins/logger.js";
+import { ApiError } from "#exceptions/api-error.js";
+import { updPool } from "#db/db.js";
+import ipaddr from "ipaddr.js";
+import { z } from "zod";
+import { DatabaseTransactionConnection } from "slonik";
+import { RefundStatusResponse } from "./payment-provider-types.js";
+import { paymentService } from "./payment-service.js";
+
+class YookassaWebhookService {
+  async handleYooKassaNotification(body: unknown) {
+    logger.info("Получено уведомление от YooKassa...");
+    const notification = YooKassaWebhookNotificationSchema.safeParse(body);
+
+    if (!notification.success) {
+      logger.error("Невалидное уведомление от YooKassa", notification.error);
+      // ЮKassa ожидает ответ 200, даже если мы не смогли обработать.
+      // Иначе она будет повторять отправку.
+      // Мы можем бросить ошибку, если хотим, чтобы она повторила.
+      throw ApiError.BadRequest(
+        "InvalidWebhookPayload",
+        "Невалидные данные вебхука",
+      );
+    }
+
+    const { event, object } = notification.data;
+
+    await updPool.transaction(async (tr) => {
+      // Проверяем тип события, чтобы понять, с каким объектом мы работаем
+      if (event.startsWith("payment.")) {
+        // Это событие о платеже
+        const paymentData = object as z.infer<
+          typeof YooKassaPaymentResponseSchema
+        >; // Приводим тип
+        logger.info(`Событие платежа: ${event}, ID платежа: ${paymentData.id}`);
+        await this.processPaymentEvent(tr, event, paymentData);
+      } else if (event.startsWith("refund.")) {
+        // Это событие о возврате
+        const refundData = object as z.infer<
+          typeof YooKassaRefundResponseSchema
+        >; // Приводим тип
+        logger.info(
+          `Событие возврата: ${event}, ID возврата: ${refundData.id}`,
+        );
+        await this.processRefundEvent(tr, event, refundData);
+      }
+    });
+  }
+
+  private async processPaymentEvent(
+    tr: DatabaseTransactionConnection,
+    event: string,
+    paymentData: z.infer<typeof YooKassaPaymentResponseSchema>,
+  ) {
+    const payment = await paymentService.getPaymentByExternalId(paymentData.id);
+    if (!payment) {
+      // Это может случиться, если вебхук пришел раньше, чем мы успели записать платеж в БД.
+      // Или если ID не наш.
+      logger.error(
+        `Платеж с external_transaction_id ${paymentData.id} не найден в нашей системе.`,
+      );
+      // В этом случае стоит вернуть ошибку, чтобы ЮKassa попробовала снова через некоторое время.
+      throw ApiError.BadRequest("PaymentNotFoundInDB", "Платеж не найден");
+    }
+
+    switch (event) {
+      case "payment.succeeded":
+      case "payment.waiting_for_capture":
+        // Статус в БД уже может быть SUCCEEDED, если сработал запасной поллер.
+        if (payment.status === "PENDING") {
+          await paymentService.processSuccessfulPayment(
+            tr,
+            payment.paymentId,
+            payment.orderId,
+          );
+          logger.info(
+            `Платеж ${payment.paymentId} успешно обработан через вебхук.`,
+          );
+        }
+        break;
+
+      case "payment.canceled":
+        if (payment.status === "PENDING") {
+          await paymentService.processFailedOrCanceledPayment(
+            tr,
+            payment.paymentId,
+            payment.orderId,
+            "CANCELED",
+          );
+          logger.info(
+            `Платеж ${payment.paymentId} отменен (обработано через вебхук).`,
+          );
+        }
+        break;
+
+      // TODO: Обработать другие события, например, refund.succeeded
+      // case "refund.succeeded":
+      //   ...
+      //   break;
+
+      default:
+        logger.warn(`Получено необрабатываемое событие: ${event}`);
+    }
+  }
+
+  private async processRefundEvent(
+    tr: DatabaseTransactionConnection,
+    event: string,
+    refundData: z.infer<typeof YooKassaRefundResponseSchema>,
+  ) {
+    switch (event) {
+      case "refund.succeeded":
+      case "refund.canceled": // Добавим и отмену на будущее
+        // 1. Маппим ответ от YooKassa в наш общий формат
+        const genericRefundStatus: RefundStatusResponse = {
+          id: refundData.id,
+          externalTransactionId: refundData.payment_id,
+          status: refundData.status,
+          amount: {
+            value: refundData.amount.value,
+            currency: refundData.amount.currency,
+          },
+          createdAt: refundData.created_at,
+          description: refundData.description,
+          // providerSpecificDetails...
+        };
+
+        // 2. Вызываем централизованный метод из PaymentService
+        await paymentService.processRefundStatusUpdate(tr, genericRefundStatus);
+        logger.info(
+          `Вебхук для возврата ${refundData.id} передан в PaymentService.`,
+        );
+        break;
+
+      default:
+        logger.warn(`Получено необрабатываемое событие возврата: ${event}`);
+    }
+  }
+
+  /**
+   * Проверка, что IP-адрес входит в доверенный список YooKassa.
+   * Обязательно для безопасности!
+   */
+  // Используем библиотеку для работы с IP-адресами и CIDR
+
+  // Список IP-адресов и диапазонов YooKassa
+  // Источник: https://yookassa.ru/developers/using-api/webhooks#ip-addresses
+  yookassaCidrs = [
+    "185.71.76.0/27",
+    "185.71.77.0/27",
+    "77.75.153.0/25",
+    "77.75.156.11/32", // Отдельные IP-адреса лучше представлять как CIDR /32
+    "77.75.156.35/32",
+    "77.75.154.128/25",
+    "2a02:5180::/32", // IPv6
+  ];
+
+  // Для локального тестирования можно добавить IP-адреса в этот список
+  trustedTestIps = ["127.0.0.1", "::1"];
+
+  // Заранее парсим CIDR-диапазоны, чтобы не делать это при каждом вызове функции.
+  // Это значительно повышает производительность.
+  yookassaRanges = this.yookassaCidrs.map(ipaddr.parseCIDR);
+
+  /**
+   * Проверяет, принадлежит ли IP-адрес доверенным подсетям YooKassa.
+   */
+  isYooKassaIp(ip: string) {
+    // 1. Проверяем, не является ли это IP для локального тестирования
+    if (this.trustedTestIps.includes(ip)) {
+      return true;
+    }
+
+    let parsedIp;
+    try {
+      // 2. Парсим входящий IP-адрес. Если формат неверный, ipaddr.js выбросит ошибку.
+      parsedIp = ipaddr.parse(ip);
+    } catch (e) {
+      // Если IP-адрес невалидный, он точно не от YooKassa.
+      console.error(`Не удалось распознать IP-адрес: ${ip}`, e);
+      return false;
+    }
+
+    // 3. Проверяем вхождение IP в каждый из диапазонов YooKassa.
+    for (const range of this.yookassaRanges) {
+      if (parsedIp.match(range)) {
+        // Как только найдено совпадение, возвращаем true
+        return true;
+      }
+    }
+
+    // 4. Если совпадений не найдено после проверки всех диапазонов
+    return false;
+  }
+}
+
+export const yookassaWebhookService = new YookassaWebhookService();

+ 31 - 0
src/services/scheduler-service.ts

@@ -0,0 +1,31 @@
+import cron from "node-cron";
+import { logger } from "#logger";
+import { paymentService } from "#modules/client/shop/payment/payment-service.js";
+
+export function startSchedulers() {
+  paymentService.reconcilePendingPayments().catch((err) => {
+    logger.error("Критическая ошибка в задаче сверки платежей:", err);
+  });
+  // Запускать каждые 30 минут
+  cron.schedule("*/30 * * * *", () => {
+    logger.info("Запуск плановой задачи сверки платежей...");
+    paymentService.reconcilePendingPayments().catch((err) => {
+      logger.error("Критическая ошибка в задаче сверки платежей:", err);
+    });
+  });
+  //
+  //
+  // --- Сверка возвратов ---
+  paymentService.reconcilePendingRefunds().catch((err) => {
+    logger.error("Критическая ошибка в задаче сверки возвратов:", err);
+  });
+  // Запускать раз в час (в 5 минут каждого часа, чтобы не совпадать с другими задачами)
+  cron.schedule("5 * * * *", () => {
+    logger.info("Запуск плановой задачи сверки возвратов...");
+    paymentService.reconcilePendingRefunds().catch((err) => {
+      logger.error("Критическая ошибка в задаче сверки возвратов:", err);
+    });
+  });
+
+  logger.info("Планировщик задач запущен.");
+}