diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..77165f9 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,17 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "pwa-node", + "request": "launch", + "name": "Launch Program", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder}\\src\\main.js" + } + ] +} \ No newline at end of file diff --git a/src/datasources/BankDataSource.js b/src/datasources/BankDataSource.js index b6a722e..1ebeeec 100644 --- a/src/datasources/BankDataSource.js +++ b/src/datasources/BankDataSource.js @@ -13,4 +13,3 @@ class BankDataSource { } module.exports = BankDataSource; - diff --git a/src/datasources/privat24/privat24.js b/src/datasources/privat24/privat24.js index f2df15a..59314b9 100644 --- a/src/datasources/privat24/privat24.js +++ b/src/datasources/privat24/privat24.js @@ -1,3 +1,184 @@ 'use strict'; -// TODO: Add implementation of Privat24 API Handler +const http = require('https'); +const xmlJs = require('xml-js'); +const crypto = require('crypto'); +const { fillTransactionsXml } = require('./utils/transactions'); +const { fillBalanceXml } = require('./utils/balance'); +const { + stringToDate, + dateToString, + divideIntoPeriods +} = require('./utils/dates'); + +const BankDataSource = require('../BankDataSource'); + +class PrivatDataSource extends BankDataSource { + constructor() { + super(); + } + + createSignature(xml, password) { + const regexp = /(?<=).*(?=<\/data)/; + const dataString = xml.match(regexp)[0]; + const md5Signature = crypto.createHash('md5') + .update(`${dataString}${password}`, 'utf8') + .digest('hex'); + const signature = crypto.createHash('sha1') + .update(md5Signature, 'utf8') + .digest('hex'); + return signature; + } + + fillXmlWithSignature(xml, signature) { + const xmlObj = xmlJs.xml2js(xml, { compact: true }); + + const xmlMerchant = xmlObj.request.merchant; + xmlMerchant.signature._text = signature; + + const xmlWithSignature = xmlJs.js2xml(xmlObj, { + spaces: 0, + compact: true, + fullTagEmptyElement: true + }); + return xmlWithSignature; + } + + handleBankResponse(cb, resolve, reject, res) { + let data = ''; + if (res.statusCode !== 200) { + const errMsg = `Server did not send data. STATUS CODE: ${res.statusCode}`; + reject(new Error(errMsg)); + } + res.setEncoding('utf8'); + res.on('data', chunk => { + data += chunk; + }); + res.on('end', () => { + cb(resolve, reject, data); + }); + res.on('error', err => reject(err)); + } + + handleTransactionsData(resolve, reject, dataXml) { + let dataObj; + try { + dataObj = xmlJs.xml2js(dataXml, { compact: true }); + } catch (err) { + reject(new Error(err)); + return; + } + + if (dataObj.response.data.error !== null) { + const errorMessage = dataObj.response.data.error._attributes.message; + reject(new Error(errorMessage)); + return; + } + + const result = []; + let transactions = dataObj.response.data.info.statements.statement; + if (!Array.isArray(transactions)) transactions = [transactions]; + transactions.map(val => result.push(val._attributes)); + resolve(result); + } + + handleBalanceData(resolve, reject, dataXml) { + let dataObj; + try { + dataObj = xmlJs.xml2js(dataXml, { compact: true }); + } catch (err) { + reject(new Error('Invalid XML')); + return; + } + + if (dataObj.response.data.error !== null) { + const errorMessage = dataObj.response.data.error._attributes.message; + reject(new Error(errorMessage)); + return; + } + const cardBalance = dataObj.response.data.info.cardbalance; + const card = cardBalance.card; + + const result = { + cardNumber: card.card_number._text, + currency: card.currency._text, + balance: cardBalance.balance._text, + availableBalance: cardBalance.av_balance._text, + dateBalance: cardBalance.bal_date._text, + creditLimit: cardBalance.fin_limit._text + }; + resolve(result); + } + + getData(dataForRequest, cb, path, xml) { + const options = { + hostname: 'api.privatbank.ua', + path, + method: 'POST', + headers: + { 'Content-Type': 'application/xml; charset=UTF-8' } + }; + const req = http.request(options); + + const password = dataForRequest.merchantPassword; + const signature = this.createSignature(xml, password); + const xmlWithSignature = this.fillXmlWithSignature(xml, signature); + req.write(xmlWithSignature); + req.end(); + + return new Promise((resolve, reject) => { + const handler = this.handleBankResponse.bind(this, cb, resolve, reject); + req.on('response', handler); + req.on('error', err => reject(err)); + }); + } + + async getTransactionsData(dataForTransactions) { + const startDate = stringToDate(dataForTransactions.startDate); + const endDate = stringToDate(dataForTransactions.endDate); + + const periods = divideIntoPeriods(startDate, endDate); + const promises = []; + + const path = '/p24api/rest_fiz'; + const xml = fillTransactionsXml(dataForTransactions); + const cb = this.handleTransactionsData.bind(this); + + periods.forEach(period => { + const copyDataForTransactions = Object.assign({}, dataForTransactions); + copyDataForTransactions.startDate = dateToString(period[0]); + copyDataForTransactions.endDate = dateToString(period[1]); + promises.push(this.getData(copyDataForTransactions, cb, path, xml)); + }); + const transactions = await Promise.all(promises); + + const result = []; + transactions.forEach(arr => result.push(...arr.reverse())); + + return result; + } + + async getBalanceData(dataForBalance) { + const result = []; + const path = '/p24api/balance'; + const xml = fillBalanceXml(dataForBalance); + const cb = this.handleBalanceData.bind(this); + for (const data of dataForBalance) { + result.push(this.getData(data, cb, path, xml)); + } + return Promise.all(result); + } + + // conf is similar to dataForTransactionsRequest + async getTransactions(card, conf) { + conf.cardNumber = card.cardNum + ''; + return this.getTransactionsData(conf); + } + /* additional method for getting balances of cards array + conf is array of objects similar to dataForBalanceRequest */ + async getBalance(conf) { + return this.getBalanceData(conf); + } +} + +module.exports = PrivatDataSource; diff --git a/src/datasources/privat24/privat24_usage.js b/src/datasources/privat24/privat24_usage.js new file mode 100644 index 0000000..48443fa --- /dev/null +++ b/src/datasources/privat24/privat24_usage.js @@ -0,0 +1,36 @@ +'use strict'; + +const PrivatDataSource = require('./privat24.js'); + +// sample data for transactions request +const dataForTransactionsRequest = { + merchantPassword: '55x3Ft9C96yx7s1cAMO2KVn1apuDA0X6', + merchantId: 123456, + wait: 10, + test: 0, + paymentId: '', + startDate: '11.05.2020', + endDate: '23.10.2020', + cardNumber: '1234567890123456' +}; + +// sample data for balance request +const dataForBalanceRequest = { + merchantPassword: '55x3Ft9C96yx7s1cAMO2KVn1apuDA0X6', + merchantId: 123456, + wait: 10, + test: 0, + paymentId: '', + cardNumber: '1234567890123456', + country: 'UA' +}; + +// example of using +(async () => { + const pds = new PrivatDataSource(); + const tranData = await pds.getTransactionsData(dataForTransactionsRequest); + const balanceData = await pds.getBalanceData([dataForBalanceRequest]); + // dataForBalanceRequest is array of data objects + console.log(tranData); + console.log(balanceData); +})(); diff --git a/src/datasources/privat24/utils/balance.js b/src/datasources/privat24/utils/balance.js new file mode 100644 index 0000000..66b1d8c --- /dev/null +++ b/src/datasources/privat24/utils/balance.js @@ -0,0 +1,32 @@ +'use strict'; + +const fs = require('fs'); +const xmlJs = require('xml-js'); +const balanceXML = fs.readFileSync('./xml_data/balance.xml', 'utf-8'); + +const fillBalanceXml = dataForBalance => { + const xmlObj = xmlJs.xml2js(balanceXML, { compact: true }); + + const xmlMerchant = xmlObj.request.merchant; + xmlMerchant.id._text = dataForBalance.merchantId; + + const xmlData = xmlObj.request.data; + xmlData.wait._text = dataForBalance.wait; + xmlData.test._text = dataForBalance.test; + + const xmlPayment = xmlData.payment; + xmlPayment._attributes.id = dataForBalance.paymentId; + + const [xmlCardNumber, xmlCountry] = xmlPayment.prop; + xmlCardNumber._attributes.value = dataForBalance.cardNumber; + xmlCountry._attributes.value = dataForBalance.country; + + const xmlWithData = xmlJs.js2xml(xmlObj, { + spaces: 0, + compact: true, + fullTagEmptyElement: true + }); + return xmlWithData; +}; + +module.exports = { fillBalanceXml }; diff --git a/src/datasources/privat24/utils/dates.js b/src/datasources/privat24/utils/dates.js new file mode 100644 index 0000000..9f93095 --- /dev/null +++ b/src/datasources/privat24/utils/dates.js @@ -0,0 +1,29 @@ +'use strict'; + +const stringToDate = date => { + const [day, month, year] = date.split('.'); + return new Date(`${year}-${month}-${day}`); +}; + +const dateToString = date => + `${date.getDate()}.${date.getMonth() + 1}.${date.getFullYear()}`; + +const divideIntoPeriods = (startDate, endDate) => { + const periods = []; + const day = 1000 * 3600 * 24; + const threeMonths = day * 30 * 3; + const periodEnd = new Date(startDate.getTime() + threeMonths); + periods.push([startDate, periodEnd]); + + while (periods[periods.length - 1][1] < endDate) { + const lastPeriodEnd = periods[periods.length - 1][1]; + const newPeriodStart = new Date(lastPeriodEnd.getTime() + day); + const newPeriodEnd = new Date(newPeriodStart.getTime() + threeMonths); + periods.push([newPeriodStart, newPeriodEnd]); + } + periods[periods.length - 1][1] = endDate; + + return periods; +}; + +module.exports = { stringToDate, dateToString, divideIntoPeriods }; diff --git a/src/datasources/privat24/utils/transactions.js b/src/datasources/privat24/utils/transactions.js new file mode 100644 index 0000000..1474dce --- /dev/null +++ b/src/datasources/privat24/utils/transactions.js @@ -0,0 +1,30 @@ +'use strict'; + +const fs = require('fs'); +const xmlJs = require('xml-js'); +const transactionsXML = fs.readFileSync('./xml_data/transactions.xml', 'utf-8'); + +const fillTransactionsXml = dataForTransactions => { + const xmlObj = xmlJs.xml2js(transactionsXML, { compact: true }); + + const xmlMerchant = xmlObj.request.merchant; + xmlMerchant.id._text = dataForTransactions.merchantId; + + const xmlData = xmlObj.request.data; + xmlData.wait._text = dataForTransactions.wait; + xmlData.test._text = dataForTransactions.test; + + const xmlPayment = xmlData.payment; + xmlPayment._attributes.id = dataForTransactions.paymentId; + + const [xmlStartDate, xmlEndDate, xmlCardNumber] = xmlPayment.prop; + xmlStartDate._attributes.value = dataForTransactions.startDate; + xmlEndDate._attributes.value = dataForTransactions.endDate; + xmlCardNumber._attributes.value = dataForTransactions.cardNumber; + + const xmlWithData = xmlJs.js2xml(xmlObj, + { spaces: 0, compact: true, fullTagEmptyElement: true }); + return xmlWithData; +}; + +module.exports = { fillTransactionsXml }; diff --git a/src/datasources/privat24/xml_data/balance.xml b/src/datasources/privat24/xml_data/balance.xml new file mode 100644 index 0000000..c536ab9 --- /dev/null +++ b/src/datasources/privat24/xml_data/balance.xml @@ -0,0 +1,16 @@ + + + + + + + + cmt + 0 + 0 + + + + + + \ No newline at end of file diff --git a/src/datasources/privat24/xml_data/transactions.xml b/src/datasources/privat24/xml_data/transactions.xml new file mode 100644 index 0000000..176dcea --- /dev/null +++ b/src/datasources/privat24/xml_data/transactions.xml @@ -0,0 +1,17 @@ + + + + + + + + cmt + + + + + + + + + \ No newline at end of file diff --git a/src/model/Transaction.js b/src/model/Transaction.js index add1336..df83df5 100644 --- a/src/model/Transaction.js +++ b/src/model/Transaction.js @@ -1,9 +1,10 @@ 'use strict'; + /** Represents transaction with card * * @property {number} cardId - Card's id in Lemon DB. * @property {number} amount - Amount of money transfered during this - * transaction. Amount is negative is money were transfered from card + * transaction. Amount is negative if money were transferred from card * and positive in other case. * @property {string} type - Description of transaction. * This value is not determined by Lemon.