yookassa-provider.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. import axios, { AxiosInstance, AxiosError } from "axios";
  2. // import { paymentConfig } from '../config/payment.config';
  3. import {
  4. IPaymentProvider,
  5. CreatePaymentParams,
  6. PaymentResponse,
  7. CreateRefundParams,
  8. RefundResponse,
  9. PaymentStatusResponse,
  10. CancelPaymentParams,
  11. CancelPaymentResponse,
  12. } from "./payment-provider-types.js";
  13. import {
  14. YooKassaCreatePaymentRequest,
  15. YooKassaPaymentResponse,
  16. YooKassaCreateRefundRequest,
  17. YooKassaRefundResponse,
  18. YooKassaErrorResponseSchema,
  19. YooKassaCreatePaymentRequestSchema,
  20. YooKassaPaymentResponseSchema,
  21. YooKassaCreateRefundRequestSchema,
  22. YooKassaRefundResponseSchema,
  23. } from "./yookassa-types.js";
  24. import { YooKassaApiError, PaymentProviderError } from "./shop-errors.js";
  25. import { v4 } from "uuid";
  26. import { z } from "zod";
  27. export class YooKassaProvider implements IPaymentProvider {
  28. private readonly axiosInstance: AxiosInstance;
  29. private readonly shopId: string;
  30. private readonly secretKey: string;
  31. private readonly apiUrl: string;
  32. constructor() {
  33. // TODO: вынести в таблицу
  34. this.shopId = "1027499";
  35. this.secretKey = "test_iTpukmMRAmujJSvL2WqoD-VNxp_bmvtjIkELGRsiUys";
  36. this.apiUrl = "https://api.yookassa.ru/v3";
  37. if (!this.shopId || !this.secretKey) {
  38. throw new Error("YooKassa Shop ID or Secret Key is not configured.");
  39. }
  40. this.axiosInstance = axios.create({
  41. baseURL: this.apiUrl,
  42. headers: {
  43. "Content-Type": "application/json",
  44. // Basic Auth: base64(shopId:secretKey)
  45. Authorization: `Basic ${Buffer.from(`${this.shopId}:${this.secretKey}`).toString("base64")}`,
  46. },
  47. timeout: 15000, // 15 секунд таймаут
  48. });
  49. }
  50. private handleError(error: unknown): never {
  51. if (axios.isAxiosError(error)) {
  52. const axiosError = error as AxiosError;
  53. if (axiosError.response && axiosError.response.data) {
  54. const parsedError = YooKassaErrorResponseSchema.safeParse(
  55. axiosError.response.data,
  56. );
  57. if (parsedError.success) {
  58. const ykError = parsedError.data;
  59. throw new YooKassaApiError(
  60. ykError.description ||
  61. `YooKassa API Error: ${ykError.code || "Unknown code"}`,
  62. ykError,
  63. );
  64. } else {
  65. // Если структура ошибки YooKassa не распознана, выбрасываем более общую ошибку
  66. throw new PaymentProviderError(
  67. `YooKassa API request failed with status ${axiosError.response.status}: ${JSON.stringify(axiosError.response.data)}`,
  68. axiosError.response.data,
  69. );
  70. }
  71. }
  72. throw new PaymentProviderError(
  73. `YooKassa request failed: ${axiosError.message}`,
  74. );
  75. }
  76. // Если это не ошибка Axios, но все же ошибка
  77. if (error instanceof Error) {
  78. throw new PaymentProviderError(
  79. `An unexpected error occurred: ${error.message}`,
  80. );
  81. }
  82. // Для совсем неизвестных случаев
  83. throw new PaymentProviderError(
  84. "An unknown error occurred during the YooKassa request.",
  85. );
  86. }
  87. private mapToYooKassaPaymentRequest(
  88. params: CreatePaymentParams,
  89. ): YooKassaCreatePaymentRequest {
  90. const ykRequest: YooKassaCreatePaymentRequest = {
  91. amount: {
  92. value: params.amount.value,
  93. currency: params.amount.currency.toUpperCase(),
  94. },
  95. capture: params.capture,
  96. description: params.description,
  97. confirmation: {
  98. // YooKassa требует confirmation, если не передается payment_method_data
  99. type: "redirect",
  100. return_url: params.returnUrl,
  101. },
  102. metadata: params.metadata,
  103. };
  104. if (params.paymentMethodType) {
  105. // Если тип метода указан, YooKassa может не требовать confirmation
  106. // Но если он указан, то return_url все равно нужен для redirect
  107. // Это упрощение, для production надо будет точнее смотреть по API YooKassa
  108. // для каждого payment_method_data
  109. const types = {
  110. CARD: "bank_card",
  111. };
  112. const formatedType = types[params.paymentMethodType];
  113. ykRequest.payment_method_data = { type: formatedType };
  114. // Если указан payment_method_data, confirmation становится опциональным
  115. // Однако, если мы хотим редирект, то confirmation всё равно нужен
  116. // Для простоты, оставляем confirmation всегда, если нужен редирект
  117. }
  118. // Валидация Zod перед отправкой (опционально, но полезно для отладки)
  119. YooKassaCreatePaymentRequestSchema.parse(ykRequest);
  120. return ykRequest;
  121. }
  122. private mapToGenericPaymentResponse(
  123. ykResponse: YooKassaPaymentResponse,
  124. ): PaymentResponse {
  125. return {
  126. id: ykResponse.id,
  127. status: ykResponse.status,
  128. paid: ykResponse.paid,
  129. amount: {
  130. value: ykResponse.amount.value,
  131. currency: ykResponse.amount.currency,
  132. },
  133. confirmation: ykResponse.confirmation
  134. ? {
  135. type: ykResponse.confirmation.type,
  136. confirmationUrl: ykResponse.confirmation.confirmation_url,
  137. }
  138. : undefined,
  139. createdAt: ykResponse.created_at,
  140. description: ykResponse.description,
  141. metadata: ykResponse.metadata,
  142. providerSpecificDetails: {
  143. // Можно добавить сюда все, что не вошло в общую модель
  144. test: ykResponse.test,
  145. expires_at: ykResponse.expires_at,
  146. payment_method: ykResponse.payment_method,
  147. refundable: ykResponse.refundable,
  148. // ... etc
  149. },
  150. };
  151. }
  152. private mapToGenericPaymentStatusResponse(
  153. ykResponse: YooKassaPaymentResponse,
  154. ): PaymentStatusResponse {
  155. // Используем логику из mapToGenericPaymentResponse и добавляем/убираем поля
  156. const genericResponse = this.mapToGenericPaymentResponse(ykResponse);
  157. delete genericResponse.confirmation; // confirmation не нужен в статусе
  158. return {
  159. ...genericResponse,
  160. test: ykResponse.test,
  161. incomeAmount: ykResponse.income_amount
  162. ? {
  163. value: ykResponse.income_amount.value,
  164. currency: ykResponse.income_amount.currency,
  165. }
  166. : undefined,
  167. refundedAmount: ykResponse.refunded_amount
  168. ? {
  169. value: ykResponse.refunded_amount.value,
  170. currency: ykResponse.refunded_amount.currency,
  171. }
  172. : undefined,
  173. };
  174. }
  175. async createPayment(
  176. params: CreatePaymentParams,
  177. idempotencyKey?: string,
  178. ): Promise<PaymentResponse> {
  179. const key = idempotencyKey || v4();
  180. const requestBody = this.mapToYooKassaPaymentRequest(params);
  181. try {
  182. // Валидируем исходящий запрос нашей Zod схемой
  183. YooKassaCreatePaymentRequestSchema.parse(requestBody);
  184. const response = await this.axiosInstance.post<YooKassaPaymentResponse>(
  185. "/payments",
  186. requestBody,
  187. {
  188. headers: { "Idempotence-Key": key },
  189. },
  190. );
  191. // Валидируем входящий ответ Zod схемой
  192. const parsedData = YooKassaPaymentResponseSchema.parse(response.data);
  193. return this.mapToGenericPaymentResponse(parsedData);
  194. } catch (error) {
  195. if (error instanceof z.ZodError) {
  196. throw new PaymentProviderError(
  197. `YooKassa data validation error: ${error.message}`,
  198. error.format(),
  199. );
  200. }
  201. this.handleError(error);
  202. }
  203. }
  204. // >>> НОВАЯ ФУНКЦИЯ ОТМЕНЫ ПЛАТЕЖА <<<
  205. async cancelPayment(
  206. params: CancelPaymentParams,
  207. idempotencyKey?: string,
  208. ): Promise<CancelPaymentResponse> {
  209. const key = idempotencyKey || v4();
  210. const { paymentId } = params;
  211. if (!paymentId) {
  212. // Дополнительная проверка, хотя Zod должен это отловить на уровне вызова
  213. throw new PaymentProviderError(
  214. "paymentId is required to cancel a payment.",
  215. );
  216. }
  217. try {
  218. // API YooKassa для отмены платежа: POST /v3/payments/{payment_id}/cancel
  219. // Тело запроса пустое.
  220. const response = await this.axiosInstance.post<YooKassaPaymentResponse>(
  221. `/payments/${paymentId}/cancel`,
  222. {}, // Пустое тело запроса
  223. {
  224. headers: { "Idempotence-Key": key },
  225. },
  226. );
  227. // Ответ от YooKassa при отмене - это обновленный объект платежа.
  228. // Валидируем его с помощью существующей схемы YooKassaPaymentResponseSchema.
  229. const parsedData = YooKassaPaymentResponseSchema.parse(response.data);
  230. // Маппим ответ YooKassa на наш общий CancelPaymentResponse,
  231. // который по структуре идентичен PaymentStatusResponse.
  232. return this.mapToGenericPaymentStatusResponse(parsedData);
  233. } catch (error) {
  234. if (error instanceof z.ZodError) {
  235. // Ошибка валидации ответа от YooKassa
  236. throw new PaymentProviderError(
  237. `YooKassa data validation error for cancelPayment response: ${error.message}`,
  238. error.format(),
  239. );
  240. }
  241. // Обработка ошибок Axios и других ошибок
  242. this.handleError(error);
  243. }
  244. }
  245. // >>> КОНЕЦ НОВОЙ ФУНКЦИИ <<<
  246. async getPaymentStatus(paymentId: string): Promise<PaymentStatusResponse> {
  247. try {
  248. const response = await this.axiosInstance.get<YooKassaPaymentResponse>(
  249. `/payments/${paymentId}`,
  250. );
  251. const parsedData = YooKassaPaymentResponseSchema.parse(response.data);
  252. return this.mapToGenericPaymentStatusResponse(parsedData);
  253. } catch (error) {
  254. if (error instanceof z.ZodError) {
  255. throw new PaymentProviderError(
  256. `YooKassa data validation error for getPaymentStatus: ${error.message}`,
  257. error.format(),
  258. );
  259. }
  260. this.handleError(error);
  261. }
  262. }
  263. private mapToYooKassaRefundRequest(
  264. params: CreateRefundParams,
  265. ): YooKassaCreateRefundRequest {
  266. const ykRequest: YooKassaCreateRefundRequest = {
  267. payment_id: params.paymentId,
  268. amount: {
  269. value: params.amount.value,
  270. currency: params.amount.currency.toUpperCase(),
  271. },
  272. description: params.description,
  273. // metadata не поддерживается API YooKassa для возвратов напрямую,
  274. // но если бы поддерживалось, добавили бы сюда params.metadata
  275. };
  276. // YooKassaCreateRefundRequestSchema.parse(ykRequest); // Опциональная валидация
  277. return ykRequest;
  278. }
  279. private mapToGenericRefundResponse(
  280. ykResponse: YooKassaRefundResponse,
  281. ): RefundResponse {
  282. return {
  283. id: ykResponse.id,
  284. paymentId: ykResponse.payment_id,
  285. status: ykResponse.status,
  286. amount: {
  287. value: ykResponse.amount.value,
  288. currency: ykResponse.amount.currency,
  289. },
  290. createdAt: ykResponse.created_at,
  291. description: ykResponse.description,
  292. providerSpecificDetails: {
  293. receipt_registration: ykResponse.receipt_registration,
  294. // ... etc
  295. },
  296. };
  297. }
  298. async createRefund(
  299. params: CreateRefundParams,
  300. idempotencyKey?: string,
  301. ): Promise<RefundResponse> {
  302. const key = idempotencyKey || v4();
  303. const requestBody = this.mapToYooKassaRefundRequest(params);
  304. try {
  305. // Валидируем исходящий запрос
  306. YooKassaCreateRefundRequestSchema.parse(requestBody);
  307. const response = await this.axiosInstance.post<YooKassaRefundResponse>(
  308. "/refunds",
  309. requestBody,
  310. {
  311. headers: { "Idempotence-Key": key },
  312. },
  313. );
  314. // Валидируем входящий ответ
  315. const parsedData = YooKassaRefundResponseSchema.parse(response.data);
  316. return this.mapToGenericRefundResponse(parsedData);
  317. } catch (error) {
  318. if (error instanceof z.ZodError) {
  319. throw new PaymentProviderError(
  320. `YooKassa data validation error for refund: ${error.message}`,
  321. error.format(),
  322. );
  323. }
  324. this.handleError(error);
  325. }
  326. }
  327. }