123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336 |
- 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<PaymentResponse> {
- const key = idempotencyKey || v4();
- const requestBody = this.mapToYooKassaPaymentRequest(params);
- try {
- // Валидируем исходящий запрос нашей Zod схемой
- YooKassaCreatePaymentRequestSchema.parse(requestBody);
- const response = await this.axiosInstance.post<YooKassaPaymentResponse>(
- "/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<PaymentStatusResponse> {
- try {
- const response = await this.axiosInstance.get<YooKassaPaymentResponse>(
- `/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<RefundResponse> {
- const key = idempotencyKey || v4();
- const requestBody = this.mapToYooKassaRefundRequest(params);
- try {
- // Валидируем исходящий запрос
- YooKassaCreateRefundRequestSchema.parse(requestBody);
- const response = await this.axiosInstance.post<YooKassaRefundResponse>(
- "/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<RefundStatusResponse> {
- try {
- const response = await this.axiosInstance.get<YooKassaRefundResponse>(
- `/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);
- }
- }
- }
|