├── .env.example ├── media └── duitnow-logo.png ├── .gitignore ├── package.json ├── readme.md ├── app.js └── index.html /.env.example: -------------------------------------------------------------------------------- 1 | 2 | APP_URL= 3 | CHIP_BRAND_ID= 4 | CHIP_API_KEY= -------------------------------------------------------------------------------- /media/duitnow-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natsu90/duitnowqr-chip/HEAD/media/duitnow-logo.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # misc 2 | .DS_STORE 3 | 4 | # nodes 5 | node_modules/ 6 | package-lock.json 7 | yarn.lock 8 | yarn-error.log 9 | sessions/ 10 | .env 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "duitnowqr-chip", 3 | "version": "1.0.0", 4 | "description": "DuitNow QR Hardwareless Soundbox", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "node app.js" 8 | }, 9 | "author": "Sulaiman Sudirman", 10 | "license": "MIT", 11 | "keywords": [], 12 | "dependencies": { 13 | "cheerio": "^1.0.0", 14 | "Chip": "https://github.com/CHIPAsia/chip-nodejs-sdk.git#v1.0.0", 15 | "dotenv": "^16.4.7", 16 | "easyqrcodejs-nodejs": "^4.5.2", 17 | "ejs": "^3.1.10", 18 | "express": "^4.18.2", 19 | "express-session": "^1.18.1", 20 | "jimp": "^1.6.0", 21 | "jsqr": "^1.4.0", 22 | "session-file-store": "^1.5.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | 2 | ## DuitNowQR Hardwareless Soundbox 3 | 4 | DuitNowQR advance payment validation, without any additional hardware, which I believe should come built-in ready in every banking app. 5 | 6 | ### Demo 7 | 8 | [http://duitnow.ss.my](http://duitnow.ss.my) 9 | 10 | [](https://youtu.be/20gtE4HNr5g) 11 | 12 | ### Prerequisites 13 | 14 | 1. SSM Company Certificate (for CHIP account and bank account registration) 15 | 16 | 2. Company Bank Account (for DuitNow activation with CHIP) 17 | 18 | 3. [CHIP Account](https://onboarding.chip-in.asia/) 19 | 20 | ### Installation 21 | 22 | 1. `npm install` 23 | 24 | 2. `cp .env.example .env` 25 | 26 | 3. Fill up `.env` accordingly 27 | 28 | ``` 29 | APP_URL= 30 | CHIP_BRAND_ID= 31 | CHIP_API_KEY= 32 | ``` 33 | 34 | 4. `npm run start` 35 | 36 | ### CHIP Informations 37 | 38 | 1. 1.6% transaction fee, or RM0.15, which one is higher. And RM1.50 maximum. 39 | 40 | 2. Next business day settlement, no minimum settlement. 41 | 42 | ### Known Issues 43 | 44 | 1. The UI is not really mobile friendly, my frontend skill is suck, feel free to contribute. 45 | 46 | 2. I tried to use Server Sent Event (SSE) at first, instead of Short Polling, but somehow SSE doesn't work with Nginx. I'll try debug it later when I'm free. 47 | 48 | ### License 49 | 50 | Licensed under the [MIT license](http://opensource.org/licenses/MIT) 51 | 52 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | 3 | const https = require('https') 4 | const express = require('express') 5 | const Chip = require('Chip').default 6 | const cheerio = require('cheerio') 7 | const jsQR = require('jsqr') 8 | const { Jimp } = require('jimp') 9 | var session = require('express-session') 10 | var FileStore = require('session-file-store')(session) 11 | const QRCode = require('easyqrcodejs-nodejs') 12 | const { randomUUID } = require('crypto') 13 | 14 | const endpoint = 'https://gate.chip-in.asia/api/v1' 15 | const basedUrl = process.env.APP_URL 16 | const brandId = process.env.CHIP_BRAND_ID 17 | const apiKey = process.env.CHIP_API_KEY 18 | let webhookPublicKey 19 | 20 | const app = express() 21 | const port = 7001 22 | 23 | app.engine('html', require('ejs').renderFile) 24 | app.disable('view cache') 25 | 26 | app.use(session({ 27 | secret: apiKey, 28 | resave: false, 29 | saveUninitialized: false, 30 | store: new FileStore, 31 | cookie: { maxAge: 3600000, secure: false, httpOnly: true }, 32 | genid: function () { 33 | return randomUUID() 34 | } 35 | }) 36 | ) 37 | 38 | app.use(function(req, res, next) { 39 | req.rawBody = '' 40 | req.setEncoding('utf8') 41 | 42 | req.on('data', function(chunk) { 43 | if (chunk) req.rawBody += chunk 44 | }) 45 | 46 | req.on('end', function() { 47 | next() 48 | }) 49 | }) 50 | 51 | Chip.ApiClient.instance.basePath = endpoint 52 | Chip.ApiClient.instance.token = apiKey 53 | 54 | const apiInstance = new Chip.PaymentApi() 55 | 56 | // Home Page 57 | app.get('/', (req, res) => { 58 | 59 | res.render(__dirname + '/index.html') 60 | }) 61 | 62 | // QR Code 63 | app.get('/pay', async (req, res) => { 64 | const sessionid = req.session.id 65 | const amount = req.query.amount || 100 66 | const client = { email: 'duitnowqr@dummy-email-address.com' } 67 | const product = { name: 'duitnowqr', price: amount } 68 | const details = { products: [ product ], currency: 'MYR' } 69 | const purchase = { 70 | brand_id: brandId, 71 | client: client, 72 | purchase: details, 73 | payment_method_whitelist: ['duitnow_qr'], 74 | success_callback: `${basedUrl}/ipn`, 75 | reference: sessionid 76 | } 77 | 78 | apiInstance.purchasesCreate(purchase, async function(error, data, response) { 79 | if (error) { 80 | console.log('API call failed. Error:', error) 81 | res.end() 82 | return 83 | } 84 | 85 | const $ = await cheerio.fromURL(data.checkout_url) 86 | const imgSrc = $('img[alt="QR code"]').attr('src') 87 | 88 | const qrBuffer = Buffer.from(imgSrc.replace(/^data:image\/[a-z]+;base64,/, ''), 'base64') 89 | 90 | // Rebuild QR 91 | const image = await Jimp.read(qrBuffer) 92 | const imageData = { 93 | data: new Uint8ClampedArray(image.bitmap.data), 94 | width: image.bitmap.width, 95 | height: image.bitmap.height, 96 | } 97 | const decodedQR = jsQR(imageData.data, imageData.width, imageData.height) 98 | 99 | const qrCode = new QRCode({ 100 | text: decodedQR.data, 101 | width: 512, 102 | height: 512, 103 | colorDark : '#FF076Aff', 104 | colorLight : '#FFFFFF', 105 | correctLevel : QRCode.CorrectLevel.H, 106 | quietZone: 12, 107 | quietZoneColor: '#FFFFFF', 108 | logo: './media/duitnow-logo.png', 109 | logoWidth: 69, 110 | logoHeight: 75, 111 | logoBackgroundColor: '#FFFFFF' 112 | }) 113 | 114 | qrCode.toDataURL().then((newImgSrc) => { 115 | newQrBuffer = Buffer.from(newImgSrc.replace(/^data:image\/[a-z]+;base64,/, ''), 'base64') 116 | res.writeHead(200, { 117 | 'Content-Type': 'image/png', 118 | 'Content-Length': newQrBuffer.length 119 | }); 120 | res.end(newQrBuffer) 121 | }) 122 | // END Rebuild QR 123 | 124 | // res.writeHead(200, { 125 | // 'Content-Type': 'image/png', 126 | // 'Content-Length': qrBuffer.length 127 | // }); 128 | // res.end(qrBuffer) 129 | }) 130 | 131 | }) 132 | 133 | // Payment Webhook 134 | app.post('/ipn', async (req, res) => { 135 | const { rawBody, headers } = req 136 | const xsignature = headers['x-signature'] 137 | const publicKey = webhookPublicKey 138 | const parsed = JSON.parse(rawBody) 139 | 140 | const verified = apiInstance.verify(rawBody, Buffer.from(xsignature, 'base64'), publicKey) 141 | 142 | if (verified && parsed.event_type === 'purchase.paid') { 143 | console.log('IPN: ', JSON.stringify(parsed, null, 4)) 144 | 145 | const sessionid = parsed.reference 146 | // push message to Short Polling 147 | req.sessionStore.get(sessionid, function(err, session) { 148 | if (err) return; 149 | 150 | session.message = parsed.event_type 151 | req.sessionStore.set(sessionid, session) 152 | }) 153 | 154 | res.send('OK') 155 | } 156 | 157 | res.end() 158 | }) 159 | 160 | // Short Polling // Alt to SSE 161 | app.get('/status', (req, res) => { 162 | 163 | const message = req.session.message 164 | // clear message after retrieved 165 | req.session.message = '' 166 | 167 | res.send(message) 168 | }) 169 | 170 | const getPublicKey = () => { 171 | return new Promise((resolve, reject) => { 172 | let data = '' 173 | https.get(`${endpoint}/public_key/`, 174 | { headers: { 175 | 'Authorization': `Bearer ${apiKey}` 176 | }, 177 | }, response => { 178 | response.on('data', chunk => { 179 | data = chunk.toString() 180 | }) 181 | 182 | response.on('end', () => { 183 | resolve(data) 184 | }) 185 | }).on('error', err => { 186 | console.log('Error: ', err.message) 187 | reject(err) 188 | }) 189 | }) 190 | } 191 | 192 | const fetchPublicKey = async() => { 193 | webhookPublicKey = JSON.parse(await getPublicKey()) 194 | } 195 | 196 | fetchPublicKey() 197 | 198 | app.listen(port, () => { 199 | return console.log( 200 | `Express is listening at http://localhost:${port}` 201 | ) 202 | }) 203 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
76 |
77 |
78 |
79 |
81 |
89 | 90 |