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