|
@@ -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();
|