Unequal fight — Tinkoff acquiring. Recurring payments

This article will be divided into several parts.

Part 1 – Introduction

Part 2 – Making a TypeScript client for Tinkoff acquiring

Part 3 – Working with MIR maps and other maps supporting 3DS V2

Prehistory

As part of the development of our SaaS solution for automating the processes of car sharing and car rental CarSense, we were faced with the task of implementing a system of recurrent payments (Adding cards by users for further direct debiting).

Initially, we used YooMoney, but the extremely high commission did not allow us to stay on it, and therefore it was decided to switch to another acquiring service. We chose Tinkoff acquiring. This decision cost us several weeks of integration. While in the case of YuKassa, integration took a couple of days.

Prologue

From the very beginning, it became clear that this would not be easy, since the documentation does not help with integration at all, and often does not describe at all what should work and how. On the Internet, I did not find anything even remotely similar to a complete solution.

It is not possible for a person in their right mind to integrate with this on their own.

But we are not like that, so we had to create our own client to organize payment acceptance. The only source of truth was the Flutter client implementation repository (link) (which is absolutely insecure given that all the keys are stored directly in it). Having found out that nothing can be done with this documentation, I began to reverse what they wrote in this repository and adapt it to the client on the back end.

In previous implementations of our mobile application, we have already used this client, but refused due to the fact that it is IMPOSSIBLE TO STORE SECRET KEYS ON THE CLIENT! Therefore, we conclude that somehow it works.

We will not describe the process of parsing the code itself, for those who are interested, they can follow the link themselves and try to figure it out.

We implemented the client in TypeScript and Nodejs. Therefore, implementation examples will be on it.

Tinkov himself provides us with a diagram (link) on which it seems to work.

Speaking of recurring payments, we see the following picture:

No one bothered to describe what happens between these two calls. Among other things, there is an AddCard method that is present in the Flutter client, and is not in the documentation. However, there is a RemoveCard in the documentation. There are answers to which there are no questions, further it will become clear.

It is probably possible to implement everything using this method, but due to the fact that it seems to have been removed from the documentation, we will not use it.

Let’s describe our basic client

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 is required in order to sign our requests. Here is a working implementation of how it should be.

Init – Initial payment creation

This method is needed to initialize the payment, but the payment itself is not created.

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

Now we use the data of our card. They need to be encrypted like this

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');
    }

In order to create a payment, you need to use the FinishAuthorize method.

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)
   })
}

We create from the received data acsUrl to which we will direct the client

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

I’ll add an EJS example of what should be on this page, because Tinkov didn’t think of doing it

<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>

In the callback, we indicated our server, where we will intercept the response from 3DS, as follows

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);
  
}

You won’t find anything about this in the documentation. There is simply no information on what to do after FinishAuthorize.

We do this with a Visa / MS card, it seems to work, right? Now we are trying to add a World map. Nothing works at all.

This, and many other, uninformative, meaningless messages, we will receive further.

Work with MIR maps and other maps supporting 3DS V2

It would seem that today, when a huge number of difficulties have arisen for the work of foreign payment systems, Tinkov, first of all, must ensure the operation of Mir cards. However, no one even bothered to explain the integration process in the documentation in an accessible way. 1.5 words are written about this there, that you need to call the Check3DSVersion method. This is where it all ends.

We’ve spent an unforgivable amount of time getting over this. But I doubt very much that anyone besides us would be ready for this. Support does not help at all, they do not have information about how the service works.

Often they either simply disappear and ignore the questions, or offer solutions that are not related to the subject of the problem.

In order to make this creation work with 3DS V2 cards, you really need to call the version check method.

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

But besides this, you need to change the FinishAuthozire request itself and bring it to the following form.

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
       },
   }

The fact that this data should be in DATA is not written in the documentation. The method will not work without them.

It is interesting that in the Flutter client, before calling it, there is a CollectData method, which 1 – is not needed, 2 – is incorrectly written in the client itself, it is so unnecessary (link)

Complete3DSMethodV2

If it is not needed, then remove it, and if it is needed, then make it work.

submit3DS for 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
}

Well, ASCUrl V2 itself

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())
}

Depending on the version, select one or another generation method

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

A very important point. URLSearchParams turns the character “%3D” into “%253D”.

This breaks the integration process. To avoid this, I do this .toString().replace(“%253D”, “%3D”)

Page to call 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>

Conclusion

I don’t know what the status of the Tinkoff Acquiring product is but the current status is totally unacceptable. It looks like the project is abandoned and no one uses it.

There is one viable client on github and this is https://github.com/MadBrains/Tinkoff-Acquiring-SDK-Flutter/, but you can’t store private keys in the mobile client. Any student is able to pull out private keys and roll back all transactions. I ask the Tinkov team to pay attention to this post, draw conclusions and bring the service to a decent state.

I hope this article will help other teams.

All the best!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *