diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4a7ea30 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..215000b --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules +lib diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..47e5d40 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +v5 diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..d2808fc --- /dev/null +++ b/.npmignore @@ -0,0 +1,3 @@ +node_modules +src +.* diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..47e5d40 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v5 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..22cacd0 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: node_js +node_js: + - "0.12" + - "4" + - "5" +notifications: + email: false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..df08691 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2016 Leonardo Gatica + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..5dfa545 --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +# tbk-oneclick + +[![npm version](https://img.shields.io/npm/v/tbk-oneclick.svg?style=flat-square)](https://www.npmjs.com/package/tbk-oneclick) +[![npm downloads](https://img.shields.io/npm/dm/tbk-oneclick.svg?style=flat-square)](https://www.npmjs.com/package/tbk-oneclick) +[![Build Status](https://img.shields.io/travis/lgaticaq/tbk-oneclick.svg?style=flat-square)](https://travis-ci.org/lgaticaq/tbk-oneclick) +[![dependency Status](https://img.shields.io/david/lgaticaq/tbk-oneclick.svg?style=flat-square)](https://david-dm.org/lgaticaq/tbk-oneclick#info=dependencies) +[![devDependency Status](https://img.shields.io/david/dev/lgaticaq/tbk-oneclick.svg?style=flat-square)](https://david-dm.org/lgaticaq/tbk-oneclick#info=devDependencies) +[![Join the chat at https://gitter.im/lgaticaq/tbk-oneclick](https://img.shields.io/badge/gitter-join%20chat%20%E2%86%92-brightgreen.svg?style=flat-square)](https://gitter.im/lgaticaq/tbk-oneclick?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) + +Node Implementation of Transbank Oneclick API SOAP + +Inspired by https://github.com/cornershop/python-oneclick + +## Installation + +```bash +npm i -S tbk-oneclick +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..64eeb4b --- /dev/null +++ b/package.json @@ -0,0 +1,83 @@ +{ + "name": "tbk-oneclick", + "version": "0.0.1", + "description": "Node Implementation of Transbank Oneclick API SOAP", + "main": "lib", + "scripts": { + "prepublish": "npm run build -s", + "prebuild": "npm run lint -s && npm run clean -s", + "build": "babel src --out-dir lib --source-maps", + "lint": "eslint src", + "clean": "rimraf lib", + "pretest": "npm run build -s", + "test": "mocha --compilers js:babel-core/register" + }, + "engines": { + "node": ">=0.12" + }, + "repository": { + "type": "git", + "url": "https://github.com/lgaticaq/tbk-oneclick.git" + }, + "keywords": [ + "tbk", + "oneclick", + "soap", + "transbank" + ], + "author": "Leonardo Gatica (https://about.me/lgatica)", + "license": "MIT", + "bugs": { + "url": "https://github.com/lgaticaq/tbk-oneclick/issues" + }, + "homepage": "https://github.com/lgaticaq/tbk-oneclick#readme", + "dependencies": { + "moment": "^2.12.0", + "node-forge": "^0.6.39", + "soap": "^0.13.0" + }, + "devDependencies": { + "babel-cli": "^6.6.5", + "babel-core": "^6.7.4", + "babel-preset-es2015": "^6.6.0", + "chai": "^3.5.0", + "eslint": "2.5.1", + "mocha": "^2.4.5", + "rimraf": "^2.5.2", + "xml2js": "^0.4.16" + }, + "eslintConfig": { + "env": { + "es6": true, + "node": true, + "mocha": true + }, + "extends": "eslint:recommended", + "parserOptions": { + "sourceType": "module" + }, + "rules": { + "indent": [ + 2, + 2 + ], + "linebreak-style": [ + 2, + "unix" + ], + "quotes": [ + 2, + "single" + ], + "semi": [ + 2, + "always" + ] + } + }, + "babel": { + "presets": [ + "es2015" + ] + } +} diff --git a/src/client.js b/src/client.js new file mode 100644 index 0000000..5a5cd96 --- /dev/null +++ b/src/client.js @@ -0,0 +1,34 @@ +'use strict'; + +import soap from 'soap'; +import {Response} from './response'; + +export class Client { + + constructor(testing=false) { + this._testing = testing; + const testingLoc = 'https://tbk.orangepeople.cl/webpayserver/wswebpay/OneClickPaymentService'; + const productionLoc = 'https://webpay3g.transbank.cl:443/webpayserver/wswebpay/OneClickPaymentService'; + this.location = testing ? testingLoc : productionLoc; + this.client = this.createClient(); + } + + createClient(cb) { + soap.createClient(this.location, cb); + } + + request(action, xml) { + try { + return new Response(this.client[action](xml), action, true); + } catch (err) { + if (err.errno === 'ECONNRESET') { + const responseError = Response('ECONNRESET', action); + responseError.error = 'ECONNRESET'; + responseError.error_msg = '[Errno 104] Connection reset by peer'; + return responseError; + } else { + throw err; + } + } + } +} diff --git a/src/document.js b/src/document.js new file mode 100644 index 0000000..fca2ea0 --- /dev/null +++ b/src/document.js @@ -0,0 +1,86 @@ +'use strict'; + +import moment from 'moment'; +import crypto from 'crypto'; +import {pki} from 'node-forge'; + +export class Document { + + constructor(action, params) { + this._action = action; + this._params = params; + this.doc = this.buildDoc(); + } + + key() { + if (!this._key) this._key = process.env.TBK_COMMERCE_KEY; + return this._key; + } + + cert() { + if (!this._cert) this._cert = process.env.TBK_COMMERCE_CRT; + return this._cert; + } + + x509() { + if (!this._x509) this._x509 = pki.certificateFromPem(this.cert()); + return this._x509; + } + + getIssuerName() { + return this.x509().issuer.attributes.map(x => `${x.shortName}=${x.value}`).join(', '); + } + + getSerialNumber() { + return parseInt(this.x509().serialNumber, 16); + } + + getDigestValue(xml) { + const shasum = crypto.createHash('sha1'); + shasum.update(xml); + return shasum.digest('base64'); + } + + obj2string(obj) { + return `{${Object.keys(obj).map(x => `'${x}': '${obj[x]}'`).join(', ')}}`; + } + + getBodyId() { + const data = `${this._action}${this.obj2string(this._params)}${moment().format('YYYY-MM-DD HH:mm:ss ZZ')}`; + return crypto.createHash('md5').update(data).digest('hex'); + } + + rsaSign(xml) { + const key = this.key().toString('ascii'); + const sign = crypto.createSign('RSA-SHA1'); + sign.update(xml); + return sign.sign(key, 'base64'); + } + + buildParamsXml(params) { + let paramsXml = ''; + for (let i of Object.keys(params)) { + paramsXml += `<${i}>${params[i]}`; + } + return paramsXml; + } + + buildDoc() { + const bodyId = this.getBodyId(); + + // 1) build body + const bodyParams = this.buildParamsXml(this._params); + const body = `${bodyParams}`; + + // 2) firm with body + const digestValue = this.getDigestValue(body); + + // 3) assign + const xmlToSign = `${digestValue}`; + const signatureValue = this.rsaSign(xmlToSign); + + // 4) build headers + return `${digestValue}${signatureValue}${this.getIssuerName()}${this.getSerialNumber()}${bodyParams}`; + } +} + diff --git a/src/oneclick.js b/src/oneclick.js new file mode 100644 index 0000000..5bb0aef --- /dev/null +++ b/src/oneclick.js @@ -0,0 +1,64 @@ +'use strict'; + +import Document from './document'; +import Request from './request'; +import Client from './client'; +import logger from './logging'; + + +export class OneClick { + + constructor(testing=false) { + this.client = new Client(testing); + } + + initInscription(email, responseUrl, username) { + const params = {email: email, username: username, responseURL: responseUrl}; + const request = new Request(params); + const d = new Document({action: 'initInscription', params: params}); + const response = this.client.request('initInscription', d.doc); + logger.generic('initInscription', request, response); + return response; + } + + finishInscription(token) { + const params = {token: token}; + const request = new Request(params); + const d = new Document({action: 'finishInscription', params: params}); + const response = this.client.request('finishInscription', d.doc); + logger.generic('finishInscription', request, response); + return response; + } + + authorize(amount, tbkUser, username, buyOrder) { + const params = { + amount: amount, + tbkUser: tbkUser, + username: username, + buyOrder: buyOrder + }; + const request = new Request(params); + const d = new Document({action: 'authorize', params: params}); + const response = this.client.request('Authorize', d.doc); + logger.generic('authorize', request, response); + return response; + } + + reverse(buyOrder) { + const params = {'buyorder': buyOrder}; + const request = new Request(params); + const d = new Document({action: 'codeReverseOneClick', params: params}); + const response = this.client.request('codeReverseOneClick', d.doc); + logger.generic('reverse', request, response); + return response; + } + + removeUser(tbkUser, username) { + const params = {tbkUser: tbkUser, username: username}; + const request = new Request(params); + const d = new Document({action: 'removeUser', params: params}); + const response = this.client.request('removeUser', d.doc); + logger.generic('removeUser', request, response); + return response; + } +} diff --git a/src/request.js b/src/request.js new file mode 100644 index 0000000..784e9ab --- /dev/null +++ b/src/request.js @@ -0,0 +1,12 @@ +'use strict'; + +export class Request { + + constructor(params) { + this._params = params; + } + + params() { + return this._params; + } +} diff --git a/test/keys/597029124456.crt b/test/keys/597029124456.crt new file mode 100644 index 0000000..441c617 --- /dev/null +++ b/test/keys/597029124456.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDWjCCAkICCQD+dO80PPmJQzANBgkqhkiG9w0BAQUFADBvMQswCQYDVQQGEwJD +TDETMBEGA1UECBMKU29tZS1TdGF0ZTERMA8GA1UEBxMIU0FOVElBR08xITAfBgNV +BAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDEVMBMGA1UEAxMMNTk3MDI5MTI0 +NDU2MB4XDTE1MDIyNTE3MjAxNVoXDTE5MDIyNDE3MjAxNVowbzELMAkGA1UEBhMC +Q0wxEzARBgNVBAgTClNvbWUtU3RhdGUxETAPBgNVBAcTCFNBTlRJQUdPMSEwHwYD +VQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxFTATBgNVBAMTDDU5NzAyOTEy +NDQ1NjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANgchO1+f/ByFIhd +AeGTro97D9qR4pXDL/hpF87qx8ip52+GUxKzTJrwhWkjA+7IN53zK5Usl5tsGlVq +aXN10QNa97mzpPPXFpVKMQfLtv5t21CTmLVRi2lTP/HCIFukyfeawmhav7DVHPqA +itCyCZ1wuCy/7kp5cqNnpVa82KWDZFsx0yKXipUYYxP7S386G/Vp4fFPFrPCW3t3 +/Mm3G/57YXYAqYTAqlMfe7NcTlzGaVvhzDYUN7CdCCwfZuIxrIciDr+SxN1jb1Zg +iIdA3TyEUvvKeeIfpr+YrwqE3JuJ05YRfwxM9WiI2oZQKXGozxroacXal4DAad59 +n1hfkbsCAwEAATANBgkqhkiG9w0BAQUFAAOCAQEAaJM1/P/ke+BwUe7MhaZTWXVl +LKxMCII2v/Leov/E1AKoIfrtm3cLbWY44IaaC43uwHcMvrq72hKG9t9b9Z5BJu/2 +Rsy3RSNCzu7x+89Af2KvwKypWBjpyfWxmnDr4VN7Lq4vcvA8ba/+u59wWDCAa5Pq +xWg727XQSuDkJS7KiIhFfGYUZwaLBfGybeo+KYYsJ3IQHZ5U6lsBoRo5OWYqmnZb +aqY6UiI5lCilR7q3bdNhGpvygyxl16ZlIKU0TG1EsvwuZuWTN5gZNkCjxM5VhZHg +NdojvzDd3dtk0jQpR0WvLxk35Cw9NItWe6sGGLpBrMddFuEenNHZHL+ftYJMCg== +-----END CERTIFICATE----- diff --git a/test/keys/597029124456.key b/test/keys/597029124456.key new file mode 100644 index 0000000..07b2a58 --- /dev/null +++ b/test/keys/597029124456.key @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA2ByE7X5/8HIUiF0B4ZOuj3sP2pHilcMv+GkXzurHyKnnb4ZT +ErNMmvCFaSMD7sg3nfMrlSyXm2waVWppc3XRA1r3ubOk89cWlUoxB8u2/m3bUJOY +tVGLaVM/8cIgW6TJ95rCaFq/sNUc+oCK0LIJnXC4LL/uSnlyo2elVrzYpYNkWzHT +IpeKlRhjE/tLfzob9Wnh8U8Ws8Jbe3f8ybcb/nthdgCphMCqUx97s1xOXMZpW+HM +NhQ3sJ0ILB9m4jGshyIOv5LE3WNvVmCIh0DdPIRS+8p54h+mv5ivCoTcm4nTlhF/ +DEz1aIjahlApcajPGuhpxdqXgMBp3n2fWF+RuwIDAQABAoIBAHlhI0Pk3eTlniSs +4GabNNr/inr/nxzwgRvrouSjt0w8KXHZJwWE0Qzg9H2FniraJ2q+bocdgZVY1T2O +Q+YGkTtqN2MExCv0bYmyHvG6+G/Use6Cx61nPH8OtAaMOvJeDtXUBUbpWWrvd5Q2 +6ECpDn9wFPGFZ0hLCBlBGHssHB5xMiz5tVnkOzQf+SJWg4h+nXls10HQPAynXidV +mlTilXzRVf1qjSmDo914OcJ57bbcYrvRCjnTV+JwNW9jC+o/+PLmTKQe7R9RmyS+ +G0EIzKMCdH0AseYOrRdhskX/pcCBEG33K19LRkVIr527O+a8agdYhX1Tl//JTNsi +OyRjAjkCgYEA+EO+pE6Adh+KTlt3kxo5mmVv8ALRI8113fmhA/nJCTbenH2ku0SE +AnFwW7MNQlV6k3hqiE+iK3Cex5yPk3iFZY885RrAZRLFhLUulhf+z6LxxKvQiJKA +AnuU5lUvzzkiQ2j/bK8LiwfxSNgyhqtRHTeqtrMYZCtKOjuHGnsAPsUCgYEA3thO +0+r3q9lpPUzLM+0O3/6jpt7hx37O4zWoh6r1b5iSOiytLECwvG6r7Jfe0pqgX8mN +xU85DhvCBjoAEU06gH4X+lA4Kcrj1kqVwXNo4JGaA9Tv9MiAWAwiX6v6gltBmq9A +86tf4XQw6N6s5M+eO3g3ZksJU0D6RkdIrmwlln8CgYBa/tGgfZl+Mj9KSyI/y6vz +WFy39wBbBBLAop+Oyn4SH4dminLXpNxR3OxW4ADrIFOGO+uoPK/vBh9cgJjrb5BN +Ujv6qVx9b2zwIEyL/Q2LY2kEMgmEFVZQEqXX6r9UT9esJ47/cgVkFywsC/ow/BgG +AoJS5r/47xkM0QbLAOxtFQKBgCZ+pTdUWo5UEyrkriF9LNmiyjBURhpJHIIBTeiJ +rrYlW/UyrIN9dUpHr+lB3trwnQ2O6q+P3OJEB0M+F67lcVqq7Ydu/hSyGKN25OGz +BwXsAPfye7UEQa90ASgXtEF6dB29cnHlQ73VbXF8rc8k0kehn6hLBAResB0dyT9g +LSoPAoGBALNZN9e0Ed/ybaq5ltl+QFaLjF2zRm2FjCI/pDxyaiRjfFPxw6zmV6ix +7ElODI6crpeAkJB0+hfGK+8mXGY3bCezNj3DEpxe6A+6CITn501GFTEten/6/N4j +i5rY1EiLoiuPJYywLi0wzUHPL//Ou3C4fQ9gXJqTEm3IXJeG6yyy +-----END RSA PRIVATE KEY----- diff --git a/test/test.js b/test/test.js new file mode 100644 index 0000000..ee6e751 --- /dev/null +++ b/test/test.js @@ -0,0 +1,25 @@ +'use strict'; + +import {Document} from '../src/document'; +import {expect} from 'chai'; +import fs from 'fs'; +import path from 'path'; +import {parseString} from 'xml2js'; + +process.env.TBK_COMMERCE_KEY = fs.readFileSync(path.join(__dirname, 'keys', '597029124456.key')); +process.env.TBK_COMMERCE_CRT = fs.readFileSync(path.join(__dirname, 'keys', '597029124456.crt')); + +describe('tbk', () => { + it('should return some parmas', done => { + const action = 'initInscription'; + const params = {username: 'lgatica', responseURL: 'https://comerce.test/oneclick', email: 'lgatica@protonmail.com'}; + const doc = new Document(action, params); + parseString(doc.doc, (err, result) => { + if (err) throw err; + expect(result['SOAP-ENV:Envelope']['SOAP-ENV:Body'][0]['ns1:initInscription'][0]['arg0'][0]['username'][0]).to.eql('lgatica'); + expect(result['SOAP-ENV:Envelope']['SOAP-ENV:Body'][0]['ns1:initInscription'][0]['arg0'][0]['responseURL'][0]).to.eql('https://comerce.test/oneclick'); + expect(result['SOAP-ENV:Envelope']['SOAP-ENV:Body'][0]['ns1:initInscription'][0]['arg0'][0]['email'][0]).to.eql('lgatica@protonmail.com'); + done(); + }); + }); +});