import axios, { AxiosInstance, AxiosError } from "axios"; // import { paymentConfig } from '../config/payment.config'; import { IPaymentProvider, CreatePaymentParams, PaymentResponse, CreateRefundParams, RefundResponse, PaymentStatusResponse, RefundStatusResponse, } from "./payment-provider-types.js"; import { YooKassaCreatePaymentRequest, YooKassaPaymentResponse, YooKassaCreateRefundRequest, YooKassaRefundResponse, YooKassaErrorResponseSchema, YooKassaCreatePaymentRequestSchema, YooKassaPaymentResponseSchema, YooKassaCreateRefundRequestSchema, YooKassaRefundResponseSchema, } from "./yookassa-types.js"; import { YooKassaApiError, PaymentProviderError } from "./shop-errors.js"; import { v4 } from "uuid"; import { z } from "zod"; export class YooKassaProvider implements IPaymentProvider { private readonly axiosInstance: AxiosInstance; private readonly shopId: string; private readonly secretKey: string; private readonly apiUrl: string; 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) { throw new Error("YooKassa Shop ID or Secret Key is not configured."); } this.axiosInstance = axios.create({ baseURL: this.apiUrl, headers: { "Content-Type": "application/json", // Basic Auth: base64(shopId:secretKey) Authorization: `Basic ${Buffer.from(`${this.shopId}:${this.secretKey}`).toString("base64")}`, }, timeout: 15000, // 15 секунд таймаут }); } private handleError(error: unknown): never { if (axios.isAxiosError(error)) { const axiosError = error as AxiosError; if (axiosError.response && axiosError.response.data) { const parsedError = YooKassaErrorResponseSchema.safeParse( axiosError.response.data, ); if (parsedError.success) { const ykError = parsedError.data; throw new YooKassaApiError( ykError.description || `YooKassa API Error: ${ykError.code || "Unknown code"}`, ykError, ); } else { // Если структура ошибки YooKassa не распознана, выбрасываем более общую ошибку throw new PaymentProviderError( `YooKassa API request failed with status ${axiosError.response.status}: ${JSON.stringify(axiosError.response.data)}`, axiosError.response.data, ); } } throw new PaymentProviderError( `YooKassa request failed: ${axiosError.message}`, ); } // Если это не ошибка Axios, но все же ошибка if (error instanceof Error) { throw new PaymentProviderError( `An unexpected error occurred: ${error.message}`, ); } // Для совсем неизвестных случаев throw new PaymentProviderError( "An unknown error occurred during the YooKassa request.", ); } private mapToYooKassaPaymentRequest( params: CreatePaymentParams, ): YooKassaCreatePaymentRequest { const ykRequest: YooKassaCreatePaymentRequest = { amount: { value: params.amount.value, currency: params.amount.currency.toUpperCase(), }, capture: params.capture, description: params.description, confirmation: { // YooKassa требует confirmation, если не передается payment_method_data type: "redirect", return_url: params.returnUrl, }, metadata: params.metadata, }; if (params.paymentMethodType) { // Если тип метода указан, YooKassa может не требовать confirmation // Но если он указан, то return_url все равно нужен для redirect // Это упрощение, для production надо будет точнее смотреть по API YooKassa // для каждого payment_method_data const types = { CARD: "bank_card", }; const formatedType = types[params.paymentMethodType]; ykRequest.payment_method_data = { type: formatedType }; // Если указан payment_method_data, confirmation становится опциональным // Однако, если мы хотим редирект, то confirmation всё равно нужен // Для простоты, оставляем confirmation всегда, если нужен редирект } // Валидация Zod перед отправкой (опционально, но полезно для отладки) YooKassaCreatePaymentRequestSchema.parse(ykRequest); return ykRequest; } private mapToGenericPaymentResponse( ykResponse: YooKassaPaymentResponse, ): PaymentResponse { return { id: ykResponse.id, status: ykResponse.status, paid: ykResponse.paid, amount: { value: ykResponse.amount.value, currency: ykResponse.amount.currency, }, confirmation: ykResponse.confirmation ? { type: ykResponse.confirmation.type, confirmationUrl: ykResponse.confirmation.confirmation_url, } : undefined, createdAt: ykResponse.created_at, description: ykResponse.description, metadata: ykResponse.metadata, providerSpecificDetails: { // Можно добавить сюда все, что не вошло в общую модель test: ykResponse.test, expires_at: ykResponse.expires_at, payment_method: ykResponse.payment_method, refundable: ykResponse.refundable, // ... etc }, }; } private mapToGenericPaymentStatusResponse( ykResponse: YooKassaPaymentResponse, ): PaymentStatusResponse { // Используем логику из mapToGenericPaymentResponse и добавляем/убираем поля const genericResponse = this.mapToGenericPaymentResponse(ykResponse); delete genericResponse.confirmation; // confirmation не нужен в статусе return { ...genericResponse, test: ykResponse.test, incomeAmount: ykResponse.income_amount ? { value: ykResponse.income_amount.value, currency: ykResponse.income_amount.currency, } : undefined, refundedAmount: ykResponse.refunded_amount ? { value: ykResponse.refunded_amount.value, currency: ykResponse.refunded_amount.currency, } : undefined, }; } async createPayment( params: CreatePaymentParams, idempotencyKey?: string, ): Promise { const key = idempotencyKey || v4(); const requestBody = this.mapToYooKassaPaymentRequest(params); try { // Валидируем исходящий запрос нашей Zod схемой YooKassaCreatePaymentRequestSchema.parse(requestBody); const response = await this.axiosInstance.post( "/payments", requestBody, { headers: { "Idempotence-Key": key }, }, ); // Валидируем входящий ответ Zod схемой const parsedData = YooKassaPaymentResponseSchema.parse(response.data); return this.mapToGenericPaymentResponse(parsedData); } catch (error) { if (error instanceof z.ZodError) { throw new PaymentProviderError( `YooKassa data validation error: ${error.message}`, error.format(), ); } this.handleError(error); } } async getPaymentStatus({ externalTransactionId, }: { externalTransactionId: string; }): Promise { try { const response = await this.axiosInstance.get( `/payments/${externalTransactionId}`, ); const parsedData = YooKassaPaymentResponseSchema.parse(response.data); return this.mapToGenericPaymentStatusResponse(parsedData); } catch (error) { if (error instanceof z.ZodError) { throw new PaymentProviderError( `YooKassa data validation error for getPaymentStatus: ${error.message}`, error.format(), ); } this.handleError(error); } } private mapToYooKassaRefundRequest( params: CreateRefundParams, ): YooKassaCreateRefundRequest { const ykRequest: YooKassaCreateRefundRequest = { payment_id: params.externalTransactionId, amount: { value: params.amount.value, currency: params.amount.currency.toUpperCase(), }, description: params.description, // metadata не поддерживается API YooKassa для возвратов напрямую, // но если бы поддерживалось, добавили бы сюда params.metadata }; // YooKassaCreateRefundRequestSchema.parse(ykRequest); // Опциональная валидация return ykRequest; } private mapToGenericRefundResponse( ykResponse: YooKassaRefundResponse, ): RefundResponse { return { id: ykResponse.id, externalTransactionId: ykResponse.payment_id, status: ykResponse.status, amount: { value: ykResponse.amount.value, currency: ykResponse.amount.currency, }, createdAt: ykResponse.created_at, description: ykResponse.description, providerSpecificDetails: { receipt_registration: ykResponse.receipt_registration, // ... etc }, }; } async createRefund( params: CreateRefundParams, idempotencyKey?: string, ): Promise { const key = idempotencyKey || v4(); const requestBody = this.mapToYooKassaRefundRequest(params); try { // Валидируем исходящий запрос YooKassaCreateRefundRequestSchema.parse(requestBody); const response = await this.axiosInstance.post( "/refunds", requestBody, { headers: { "Idempotence-Key": key }, }, ); // Валидируем входящий ответ 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 refund: ${error.message}`, error.format(), ); } this.handleError(error); } } async getRefundStatus({ externalRefundId, }: { externalRefundId: string; }): Promise { try { const response = await this.axiosInstance.get( `/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); } } }