Неравный бой — Tinkoff эквайринг. Рекуррентные платежи

Данная статья будет разбита на несколько частей. 

Часть 1 — Вступление

Часть 2 — Делаем клиент на TypeScript для Tinkoff эквайринг

Часть 3 — Работа с картами МИР и иными картами поддерживающими 3DS V2

Предистория

В рамках разработки нашего SaaS решения для автоматизации процессов кар-шерингов и авто-ренталов CarSense, перед нами стояла задача реализации системы рекуррентных платежей (Добавление карт пользователями для дальнейшего безакцептного списания). 

Изначально мы использовали YooMoney, но крайне высокая комиссия не позволяла нам остаться на нем, в связи с чем было принято решение перейти на иной сервис эквайринга. Мы выбрали Tinkoff эквайринг. Данное решение стоило нам нескольких недель интеграции. В то время как в случае с ЮКассой интеграция заняла пару дней.

Пролог

С самого начала стало понятно, что будет это непросто, так как документация совершенно не помогает в интеграции, а, зачастую, вообще не описывает что и как должно работать. В интернете я не нашел ничего хотя бы отдаленно похожего на завершенное решение. 

Интегрироваться с этим самостоятельно человеку в здравом уме не представляется возможным. 

Но мы не такие, поэтому нам нужно было сделать свой клиент для организации приема платежей. Единственным источником правды был репозиторий реализации клиента на Flutter (ссылка) (что абсолютно небезопасно учитывая что все ключи хранятся прямо в нем). Выяснив, что сделать с этой документацией ничего нельзя, я начал реверсить то, что они написали в этом репозитории и адаптировать под клиент на бекенде. 

4f2931766b6eadecdb4a38a5c02fe561.png

В предыдущих реализациях нашего мобильного приложения мы уже пользовались этим клиентом, но отказались в силу того что НЕЛЬЗЯ ХРАНИТЬ СЕКРЕТНЫЕ КЛЮЧИ НА КЛИЕНТЕ! Следовательно, делаем вывод что как-то оно работает.

Сам процесс разбора кода мы описывать не будем, кому интересно, могут сами пройти по ссылке и попробовать разобраться.

Клиент мы реализовывали на TypeScript и Nodejs. Потому и примеры реализации будут на нем.

 Сам Тиньков предоставляет нам схему (ссылка) по которой оно как бы работает.

Говоря о рекуррентных платежах мы видим следующую картину:

0b8b9461e38660003bd81e38d4e2ad1d.png

Что происходит между этими двумя вызовами никто описать не удосужился. Помимо прочего существует метод AddCard который присутствует в Flutter клиенте, и отсутствует в документации. Однако RemoveCard в документации есть. Есть ответы на которые нету вопросов, дальше это станет ясно.

Реализовать все с использованием этого метода наверное можно, но в силу того, что из документации его судя по всему удалили, пользоваться им мы не будем. 

Опишем наш базовый клиент

import axios from "axios";
import crypto from "crypto";
import {Methods, PaymentOptions} from "../../../types";

export interface HTTPRequestOptions {
   baseURL?: string,
   route?: string,
   data?: any,
   params?: any,
   method: "GET" | "POST",
   headers?: any,
   auth?: any
}

class Tinkoff {
   private readonly publicKey: string;
   private readonly apiToken: string;
   private readonly password: string;
   private readonly baseURL: string;

   constructor(
       publicKey,
       apiToken,
       password,
   ) {
       this.baseURL = "https://securepay.tinkoff.ru/v2/";
       this.apiToken = publicKey
       this.password = apiToken
       this.publicKey = password
   }

   public async request(options: HTTPRequestOptions) {
       let requestOptions = {
           url: this.baseUrl,
           method: options.method,
           headers: options.headers,
           auth: options.auth,
           data: options.data,
       }

       const resp = (await axios(requestOptions)).data

       if ((resp.ErrorCode && resp.ErrorCode != '0')) {
           console.error(resp)
           throw new Error(resp.Message)
       }
       return resp
   }

   private generateSignature(data: any) {
       const ignoredKeys = [
           'Shops',
           'Token',
           'Receipt',
           'DATA',
       ];
       const sortedValues = Object.keys({
           ...data,
           "Password": this.password
       }).filter(key => !ignoredKeys.includes(key)).sort().map(key => data[key]).join("");
       return crypto.createHash('sha256').update(sortedValues).digest('hex')
   }

   private signRequestPayload(params: any) {
       return {
           ...params,
           Token: this.generateSignature(params)
       };
   }
}

signRequestPayload необходим для того, чтобы подписывать наши запросы. Здесь описана рабочая реализация того как это должно быть.


Init — Первичное создание платежа


Данный метод нужен для того чтобы инициализировать платеж, но сам платеж при этом не создается.

public async initPayment(options): Promise {
  return this.request({ route: "Init", 
                       data: this.signRequestPayload({
                         ...options, TerminalKey: this.apiToken 
                       }), 
                       method: Methods.POST 
                      }) 
}

Теперь используем данные нашей карты. Их нужно зашифровать следующим образом

private encryptCardData(card: CardData) {
        const mergedData: string[] = [];
        // console.log(card)
        mergedData.push(`${JsonKeys.pan.toUpperCase()}=${card.number}`);
        mergedData.push(`${JsonKeys.expDate}=${card.expDate.split("/").join("")}`);
        // Optional keys
        if (card.cardHolder) {
            mergedData.push(`${JsonKeys.cardHolder}=${card.cardHolder}`);
        }
        if (card.cavv) {
            mergedData.push(`${JsonKeys.cavv}=${card.cavv}`);
        }
        if (card.eci) {
            mergedData.push(`${JsonKeys.eci}=${card.eci}`);
        }

        if (card.cvv) {
            mergedData.push(`${JsonKeys.cvv}=${card.cvv}`);
        }

        let message = mergedData.join(';');

        const encrypted = crypto.publicEncrypt(
            {
                key: this.publicKey,
                padding: crypto.constants.RSA_PKCS1_PADDING,
            }, Buffer.from(message));

        return encrypted.toString('base64');
    }

Для того, чтобы платеж создать нужно использовать метод FinishAuthorize.

private async finishAuthorize(paymentId: string, encCard: string) {
   const requestParams = {
       PaymentId: parseInt(paymentId),
       TerminalKey: this.apiToken,
       CardData: encCard,
   }

   return this.request({
       route: 'FinishAuthorize',
       method: Methods.POST,
       data: this.signRequestPayload(requestParams)
   })
}

Создаем из полученных данных acsUrl на который направим клиента 

acsUrl = /payment/tinkoff/acs/v1? + new URLSearchParams({
   acsUrl: finishAuthorized.ACSUrl,
   md: finishAuthorized.MD,
   paReq: finishAuthorized.PaReq,
   termUrl: /payment/tinkoff/acs/callback,
})

Добавлю вам пример на EJS того, что должно быть на этой странице, потому что Тиньков этого сделать не придумал


   
   

В колбек мы указали свой сервер, где перехватим ответ от 3DS, следующим образом

public async submit3DS(req) {
   let paRes: string = await streamToString(req)
   let pa = await axios.post('Submit3DSAuthorization', paRes, {
       headers: {
           'Content-Type': 'application/x-www-form-urlencoded'
       },
   })

   const success = pa.data.Success
   const errorCode = pa.data.ErrorCode
   const paymentId = pa.data.PaymentId
   const errorMessage = pa.data.Message

   return !!(!success || errorCode !== '0' || errorMessage);
  
}

В документации вы об этом ничего не найдете. Там просто отсутствует информация о том, что делать после FinishAuthorize.

Делаем это с картой Visa / MS, вроде бы работает да? Теперь пробуем добавить карту Мир. Не работает вообще ничего. 

6da256b8e16c2a6fd8976fa4c7eb8e6a.png

Это, и множество других, неинформативных, бессмысленных сообщений мы получим дальше. 

Работа с картами МИР и иными картами поддерживающими 3DS V2

Казалось бы что на сегодняшний день, когда образовалось огромное количество сложностей для работ зарубежных платежных систем Тиньков в первую очередь должен обеспечить работу карт Мир. Однако никто даже не парился о том, чтобы в документации доступно объяснить процесс интеграции. Об это там написано 1.5 слова, что нужно вызвать метод Check3DSVersion. На этом все заканчивается. 

Мы потратили непростительное количество времени, чтобы это преодолеть. Но я очень сомневаюсь, что кто-то помимо нас готов бы был на это. Поддержка совершенно не помогает, они не обладают информацией о том, как работает сервис. 

Зачастую они либо просто пропадают и игнорируют вопросы, либо предлагают решения не относящиеся к предмету проблемы. 

Для того чтобы заставить это творение работать с картами 3DS V2 действительно необходимо вызвать метод проверки версии.

private async check3DSVersion(paymentId, cardData) {
   return this.request({
       route: 'Check3dsVersion',
       method: Methods.POST,
       data: this.signRequestPayload({
           TerminalKey: this.apiToken,
           PaymentId: paymentId,
           CardData: cardData,
       })
   })
}

Но помимо этого, нужно поменять сам запрос FinishAuthozire и привести его к следующему виду. 

private async finishAuthorize(paymentId: string, encCard: string) {
   const requestParams = {
       PaymentId: parseInt(paymentId),
       TerminalKey: this.apiToken,
       CardData: encCard,
       Route: 'ACQ',
       Source: 'cards',
       DATA: {
           threeDSCompInd: 'Y',
           language: 'en',
           timezone: '180',
           screen_height: '1800',
           screen_width: '2880',
           cresCallbackUrl: ${config.app.handle}/payment/tinkoff/acs/callback?v2=true
       },
   }

О том что эти данные должны быть в DATA в документации ничего не написано. Без них метод работать не будет.

Интересно, что в Flutter клиенте перед его вызовом есть метод CollectData, который 1 — не нужен, 2 — неправильно написан в самом клиенте, настолько он не нужен  (ссылка)

7ce8cb62e67d15b6a7560dfd64106f77.png

Complete3DSMethodV2

Если он не нужен, то уберите его, а если нужен, то сделайте, чтобы он работал

submit3DS для 3DS V2

public async submit3DS(req: Request, v2: boolean = false) {
   let paRes: string = await streamToString(req)
   let pa = await axios.post(${config.tinkoff!.baseURL}${v2 ? 'Submit3DSAuthorizationV2' : 'Submit3DSAuthorization'}, paRes, {
       headers: {
           'Content-Type': 'application/x-www-form-urlencoded'
       },
   })

   const success = pa.data.Success
   const errorCode = pa.data.ErrorCode
   const paymentId = pa.data.PaymentId
   const errorMessage = pa.data.Message
  
   if (!success || errorCode !== '0' || errorMessage) {
       return paymentId
   }
   return false
}

Ну и сам ASCUrl V2

private _createCreq(serverTransId?: string, acsTransId?: string, version?: string) {
   const params = {
       ...(serverTransId != null ? {"threeDSServerTransID": serverTransId} : {}),
       ...(acsTransId != null ? {"acsTransID": acsTransId} : {}),
       ...(version != null ? {"messageVersion": version} : {}),
       challengeWindowSize: '05',
       messageType: 'CReq',
   };

   // return encodeURIComponent(btoa(JSON.stringify(params)).trim(););
   return encodeURIComponent(Buffer.from(JSON.stringify(params)).toString("base64").trim())
}

В зависимости от версии выбираем тот или иной метод генерации

acsUrl = ${config.app.handle}/payment/tinkoff/acs/v2? + new URLSearchParams({
   acsUrl: finishAuthorized.ACSUrl,
   creq:this._createCreq(finishAuthorized.TdsServerTransId, finishAuthorized.AcsTransId, check3DsResp.Version)
})

Очень важный момент. URLSearchParams превращает симовл »%3D» в »%253D». 

Это ломает процесс интеграции. Чтобы этого избежать я делаю так .toString ().replace (»%253D»,»%3D»)

Страница для вызова ACS 



Заключение 

Я не знаю каков статус продукта Tinkoff Acquiring, но Текущее его состояние абсолютно неприемлемо. Выглядит так будто проект заброшен и им никто не пользуется. 

На гитхабе есть один жизнеспособный клиент и это https://github.com/MadBrains/Tinkoff-Acquiring-SDK-Flutter/, но нельзя хранить приватные ключи в мобильном клиенте. Любой школьник в состоянии вытащить приватные ключи и откатить все проведенные транзакции. Прошу команду Тиньков обратить внимание на данный пост, сделать выводы и привести сервис в достойное состояние.

Надеюсь данная статья поможет другим командам. 

Всех благ!

© Habrahabr.ru