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

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

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

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

Предистория

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

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

Пролог

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

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

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

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

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

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

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

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

Что происходит между этими двумя вызовами никто описать не удосужился. Помимо прочего существует метод 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 того, что должно быть на этой странице, потому что Тиньков этого сделать не придумал

<html>
   <body onload="document.form.submit();">
   <form name="payForm" action="<%= acsUrl %>" method="POST">
       <input type="hidden" name="TermUrl" value="<%= termUrl %>">
       <input type="hidden" name="MD" value="<%= md %>">
       <input type="hidden" name="PaReq" value="<%= paReq %>">
   </form>
   <script>
     window.onload = submitForm;

     function submitForm() {
       payForm.submit();
     }
   </script>
   </body>
</html>

В колбек мы указали свой сервер, где перехватим ответ от 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, вроде бы работает да? Теперь пробуем добавить карту Мир. Не работает вообще ничего. 

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

Работа с картами МИР и иными картами поддерживающими 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 - неправильно написан в самом клиенте, настолько он не нужен  (ссылка)

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 

<html>
<body onload="document.form.submit();">
<form name="payForm" action="<%= acsUrl %>" method="POST">
   <input type="hidden" name="creq" value="<%= creq %>">
</form>
<script>
 window.onload = submitForm;

 function submitForm() {
   payForm.submit();
 }
</script>
</body>
</html>

Заключение 

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

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

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

Всех благ!

???? Сказать "Спасибо": USDT TRC20 TPVQGCoPbyDZfcjAxVi1VmdApkVCifoqcm

Комментарии (13)


  1. Akuma
    02.07.2023 07:14
    +1

    Вот вам на будущее с этим эквайрингом :)

    https://habr.com/ru/articles/661815/


  1. MiT_73
    02.07.2023 07:14

    Как разработчик реализации клиента на Flutter могу сказать пару вещей:

    1. Все ключи не хранятся прямо в нем, хотя такая возможность есть, как и возможность passwordless режима (в этом режиме не все запросы доступны) или подпись запроса со стороны. Реализация писалась так, чтобы её можно было использовать как для мобилок, так и для сервера.

    2. Документация у них и вправду плохая, хотя раньше была pdf версия (сейчас не смог найти) которая была полная по сравнению с тем что есть на сайте.

    3. CollectData нужен, так как 3DSv2 имеет два пути проверки. https://habr.com/ru/articles/445394/


    1. number16busshelter Автор
      02.07.2023 07:14
      +1

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

      По поводу второго пункта:

      1. Почему работает без него?

      2. Почему в самой реализации вашего клиента указан неверный роут конфирмации?

      3. Для чего он вообще нужен?


      1. MiT_73
        02.07.2023 07:14

        Такого сценария в вашем репозитории не описано

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

        CollectData нужен для проверки клиента при проверки 3DSv2 (если карта поддерживается) через мета данные. Если клиент подтверждается, то клиенту нет необходимости в прохождении полного флоу проверки (ввода кода из смс). Если клиент не подтверждён, то он проходит полный флоу проверки, т.е. по 3DSv1.

        Почему работает без него?

        Надо спросить у Тинькофф, но предположу что это не обязательное условие для проверки.

        Почему в самой реализации вашего клиента указан неверный роут конфирмации?

        А какой правильный? И вы точно проверяли работу клиента на Flutter?


        1. number16busshelter Автор
          02.07.2023 07:14

          А какой правильный? И вы точно проверяли работу клиента на Flutter?

          WebViewMethods.complete3DSMethodV2 = 'Complete3DSMethodv2'

          Хотя должен быть 'Complete3DSMethodV2'. Протестируйте в ручном режиме.

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

          При всем уважении, использовать Flutter на сервере это очень сомнительная идея. В любом случае спасибо за вашу работу!

          как и возможность passwordless режима

          На сколько я помню, 3DS V2 не будет так работать.


          1. MiT_73
            02.07.2023 07:14

            При всем уважении, использовать Flutter на сервере это очень сомнительная идея.

            А никто не говорить про Flutter на сервере ;) Там первая библиотека которая работает с api написана на чистом dart'е, а dart можно спокойно поднять на сервере.

            Хотя должен быть 'Complete3DSMethodV2'

            Хм, похоже поменяли, спасибо передам чтобы проверили.

            На сколько я помню, 3DS V2 не будет так работать.

            Будет, passwordless режим на это не влияет.


        1. dvb1
          02.07.2023 07:14

          Не знаю как именно у этого ACS реализовано, но обычно collectData позволяет проверить устройство. Например, реализовать stay signed in. Но если этого нет - ничего страшного. Просто пользователь будет заново аутенфицирован.


          1. number16busshelter Автор
            02.07.2023 07:14

            Да, конечно, скорее всего так и есть, и оно именно для этого и используется, но как этим пользоваться в случае с Тинькофф, не совсем понятно.


  1. mikaakim
    02.07.2023 07:14

    Текущая документация вовсе не содержит необходимости передавать данные карты в открытом виде, а процесс оплаты происходит следующим образом (типичным для всех платежных шлюзов):

    1. Формирование запроса на оплату со своим идентификатором заказа

    2. Отправка запроса на оплату в API для получения ссылки на платежную форму и идентификатор процесса оплаты (и его сохранение)

    3. Перенаправление пользователя на платежную форму

    4. Получение статуса оплаты при возвращении пользователя на success URL и/или проверка оплаты в бекграунде по идентификатори процесса оплаты

    5. PROFIT

    Также, как я вижу, вся информация у них представлена по ссылке для обычных платежей и реккурентных платежей https://www.tinkoff.ru/kassa/develop/api/autopayments/

    Какие возникли трудности при использовании данной документации?


    1. number16busshelter Автор
      02.07.2023 07:14

      Tinkoff support reply detected.

      Похвальное понимание теории, однако реальность жестоко отличается от воображений о том как что должно работать и как оно работает на самом деле. Могу по пунктам вам разобрать ваш комментарий:

      не содержит необходимости передавать данные карты в открытом 

      Откуда вы это взяли вообще представления не имею

      Отправка запроса на оплату в API для получения ссылки на платежную форму и идентификатор процесса оплаты (и его сохранение)

      ну отправьте запрос на карту Мир с самопильного клиента, потом напишите результат.

      Получение статуса оплаты при возвращении пользователя на success URL и/или проверка оплаты в бекграунде по идентификатори процесса оплаты

      Упустили шаг Submit3DSAuthorization. В документации он не описан корректно

      Если у вас все так хорошо и отлично получается то пост просто не для вас, листайте дальше ????‍♂️


      1. mikaakim
        02.07.2023 07:14
        +1

        Да, я в некоторых пунктах рассказал теорию, не смотря на практику. И зачем Тиньков так усложнил 3DS - фиг знает.

        Претензию снимаю. Полез в доку смотреть раздел обычных "Платежей", на которые вы указали и там есть всё про передачу платежных данных карты.

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

        А почему у вас есть нужда в двухстадийном формате оплаты, если вы описываете реккурентные платежи? У вас есть сертификация PCI DSS для внедрения собственной платежной формы или как?

        По тексту поста видно что вы используете двустадийный способ оплаты с первичной блокировкой средств и последующим подтверждением оплаты и фактического списания средств. Почему вы выбрали конкретно этот вариант?


        1. number16busshelter Автор
          02.07.2023 07:14

          А почему у вас есть нужда в двухстадийном формате оплаты, если вы описываете реккурентные платежи

          Двухстадийная оплата нужна для блокировки средств в качестве депозита и последующего списания за аренду Рекуррентный платеж необходим для повторного проведения операции аренды в целях удобства пользователя. Он не подразумевает что платеж одностадийный.

          У вас есть сертификация PCI DSS

          Сейчас очень непросто получить сертификацию PCI DSS, но мы работаем в этом направлении, и соблюдаем стандарты качества - храним пользовательские данные карт в обфусцированном виде без CVV, и передаем в зашифрованном от клиента к серверу.

          Почему вы выбрали конкретно этот вариант?

          Аренда может быть длительная, до нескольких месяцев, чтобы не дергать пользователя с оплатой, она происхоит безактепционно. Таким образом пользователь может оплачивать свою аренду постепенно


          1. mikaakim
            02.07.2023 07:14
            +1

            Спасибо за детальный ответ