yookassa-provider.ts 11 KB

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