├── .gitignore ├── images ├── demo.png ├── duitnow-logo.png ├── duitnow-values.png └── massage-chair.jpeg ├── .env.example ├── package.json ├── readme.md ├── index.html └── app.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env 3 | .DS_Store 4 | package-lock.json -------------------------------------------------------------------------------- /images/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natsu90/duitnowqr-razer/HEAD/images/demo.png -------------------------------------------------------------------------------- /images/duitnow-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natsu90/duitnowqr-razer/HEAD/images/duitnow-logo.png -------------------------------------------------------------------------------- /images/duitnow-values.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natsu90/duitnowqr-razer/HEAD/images/duitnow-values.png -------------------------------------------------------------------------------- /images/massage-chair.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/natsu90/duitnowqr-razer/HEAD/images/massage-chair.jpeg -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | 2 | DUITNOW_VENDOR=890087 3 | DUITNOW_ACCOUNT= 4 | DUITNOW_MERCHANT_CATEGORY=7372 5 | DUITNOW_MERCHANT_NAME="DUITNOW.SS.MY" # to display in bank statement 6 | DUITNOW_REF82= 7 | 8 | RAZER_SECRET_KEY= -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "duitnowqr-razer", 3 | "version": "1.0.0", 4 | "description": "DuitNow QR Realtime Notification Demo", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "node app.js" 8 | }, 9 | "author": "Sulaiman Sudirman", 10 | "license": "MIT", 11 | "dependencies": { 12 | "dotenv": "^16.3.1", 13 | "duitnow-js": "gist:f45dc88b38a037325ad9095163b82b42", 14 | "easyqrcodejs-nodejs": "^4.4.5", 15 | "ejs": "^3.1.9", 16 | "express": "^4.18.2", 17 | "express-formidable": "^1.2.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | 2 | ## DuitNow QR Realtime Notification Demo 3 | 4 | Proof of concept of Gintell Rest N Go massage chair transaction using DuitNow QR 5 | 6 | ![Massage Chair](/images/massage-chair.jpeg "Massage Chair") 7 | 8 | ### Demo 9 | 10 | [http://duitnow.ss.my](http://duitnow.ss.my) 11 | 12 | ![Demo](/images/demo.png "Demo") 13 | 14 | 1. A unique order ID is assigned to the webpage each time it is loaded. 15 | 16 | 2. A DuitNow QR code is dynamically generated with the order ID imprinted. 17 | 18 | 3. The IPN webhook will try to catch any DuitNow payment that matched the order ID. 19 | 20 | 4. If there is any payment with the matched order ID, the webhook will pass a necesary message and trigger a popup to the opened webpage earlier. 21 | 22 | ### Prerequisites 23 | 24 | 1. SSM Company Registration Number 25 | 2. [Razer Merchant Account](https://booster.merchant.razer.com) 26 | 27 | ### Installation 28 | 29 | 1. `npm install` 30 | 31 | 2. `cp .env.example .env` 32 | 33 | 3. Fill up `.env` 34 | 35 | ``` 36 | DUITNOW_ACCOUNT= 37 | DUITNOW_REF82= 38 | RAZER_SECRET_KEY= 39 | ``` 40 | 41 | 4. `npm run start` 42 | 43 | 5. Set Notification URL to `http://your-app-url/ipn`, and tick Enable Instant Payment Notification (IPN) checkbox in [Razer Merchant Portal](https://portal.merchant.razer.com) in Transactions > Settings 44 | 45 | ### Getting DUITNOW_ACCOUNT & DUITNOW_REF82 value 46 | 47 | 1. Login to [Razer Merchant Portal](https://portal.merchant.razer.com) 48 | 49 | 2. Go to Payment Link > Generate Static QR-Code 50 | 51 | 3. Fill up Channel (DuitNow QR Offline), Currency (MYR), & Order ID / Item ID (any value does not matter but keep it in mind), then click Generate Preview 52 | 53 | 4. Scan the generated QR code with a QR reader app 54 | 55 | 5. Paste the QR string into a notepad 56 | 57 | 6. Grab `DUITNOW_ACCOUNT` value between `0014A000000615000101068900870228` and `5204737253034585802MY` (in my case it is `0000000000000000000000091507`) 58 | 59 | 7. Grab `DUITNOW_REF82` value between your Order ID / Item ID value earlier + `8232` and last 8 characters (in my case it is `47FCECA2796DDB8C0D63753C1131BD85`) 60 | 61 | ![DuitNow Values](/images/duitnow-values.png "DuitNow Values") 62 | 63 | ### Limitations 64 | 65 | 1. Money is not credited directly to bank account 66 | 67 | 2. Minimum RM100 settlement 68 | 69 | 3. Transaction fee 0.85% 70 | 71 | 4. Annual fee RM99/year, waived 1st year 72 | 73 | ### License 74 | 75 | Licensed under the [MIT license](http://opensource.org/licenses/MIT) 76 | 77 | 78 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | DuitNow QR Realtime Notification Demo 4 | 5 | 14 | 15 | 16 | 17 | 18 |
19 |
20 |

21 |

Order ID: <%= refid %>

22 |
23 |
24 | 25 | 48 | 49 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | 2 | require('dotenv').config() 3 | 4 | const express = require('express') 5 | const formidable = require('express-formidable') 6 | const { generateDuitNowStr } = require('duitnow-js') 7 | const { randomUUID, createHash } = require('crypto') 8 | const QRCode = require('easyqrcodejs-nodejs') 9 | 10 | const app = express() 11 | const port = 3001 12 | let clients = []; 13 | 14 | app.use(formidable()) 15 | app.engine('html', require('ejs').renderFile) 16 | app.disable('view cache') 17 | 18 | // Home page 19 | app.get('/', (req, res) => { 20 | 21 | const refid = randomUUID() 22 | 23 | newClient = { 24 | id: refid 25 | } 26 | clients.push(newClient) 27 | 28 | res.render(__dirname + '/index.html', {refid: refid}) 29 | }) 30 | 31 | // DuitNow QR Image 32 | app.get('/qr/:refid', async (req, res) => { 33 | 34 | const refid = req.params.refid 35 | const qrString = generateDuitNowStr({ 36 | app: process.env.DUITNOW_VENDOR, 37 | account: process.env.DUITNOW_ACCOUNT, 38 | category: process.env.DUITNOW_MERCHANT_CATEGORY, 39 | ref5: refid, 40 | ref6: process.env.DUITNOW_MERCHANT_NAME, // display in Razer report in Bill Name column 41 | ref82: process.env.DUITNOW_REF82, 42 | name: process.env.DUITNOW_MERCHANT_NAME // display in Bank statement 43 | }) 44 | 45 | const qrCode = new QRCode({ 46 | text: qrString, 47 | width: 512, 48 | height: 512, 49 | colorDark : '#FF076Aff', 50 | colorLight : '#FFFFFF', 51 | correctLevel : QRCode.CorrectLevel.H, 52 | quietZone: 12, 53 | quietZoneColor: '#FFFFFF', 54 | logo: './images/duitnow-logo.png', 55 | logoWidth: 69, 56 | logoHeight: 75, 57 | logoBackgroundColor: '#FFFFFF' 58 | }) 59 | 60 | qrCode.toDataURL().then((base64data) => { 61 | base64data = base64data.replace(/^data:image\/png;base64,/, '') 62 | img = Buffer.from(base64data, 'base64') 63 | res.writeHead(200, { 64 | 'Content-Type': 'image/png', 65 | 'Content-Length': img.length 66 | }); 67 | res.end(img) 68 | }) 69 | }) 70 | 71 | // Server-Sent Events 72 | app.get('/sse/:refid', (req, res) => { 73 | const refid = req.params.refid 74 | 75 | console.log(`${refid} Connection opened`) 76 | 77 | const headers = { 78 | 'Content-Type': 'text/event-stream', 79 | 'Connection': 'keep-alive', 80 | 'Cache-Control': 'no-cache', 81 | 'X-Accel-Buffering': 'no' 82 | } 83 | res.writeHead(200, headers) 84 | 85 | const data = `id: ${refid}\n\n`; 86 | res.write(data); 87 | 88 | // save Response object to use later 89 | const clientIndex = clients.findIndex(client => client.id == refid) 90 | if (clientIndex >= 0) 91 | clients[clientIndex].response = res 92 | 93 | req.on('close', () => { 94 | console.log(`${refid} Connection closed`) 95 | 96 | clients = clients.filter(client => client.id !== refid) 97 | }) 98 | }) 99 | 100 | // Short Polling // because SSE does not work on cloud 101 | app.get('/poll/:refid', (req, res) => { 102 | 103 | let message = '' 104 | const refid = req.params.refid 105 | const clientIndex = clients.findIndex(client => client.id == refid) 106 | 107 | if (clientIndex >= 0 && clients[clientIndex].hasOwnProperty('message')) { 108 | message = clients[clientIndex].message 109 | clients[clientIndex].message = '' 110 | } 111 | 112 | res.send(message) 113 | }) 114 | 115 | // Razer Callback URL 116 | app.post('/ipn', (req, res) => { 117 | 118 | console.log(req.fields) 119 | 120 | // validate signature 121 | const preSkey = createHash('md5').update(req.fields.tranID + req.fields.orderid + req.fields.status + req.fields.domain + req.fields.amount + req.fields.currency).digest('hex') 122 | const sKey = createHash('md5').update(req.fields.paydate + req.fields.domain + preSkey + req.fields.appcode + process.env.RAZER_SECRET_KEY).digest('hex') 123 | 124 | if (sKey !== req.fields.skey) 125 | return res.status(400).send('Bad Request') 126 | 127 | const refid = req.fields.orderid 128 | const clientIndex = clients.findIndex(client => client.id == refid) 129 | if (clientIndex < 0) return res.send('OK') 130 | 131 | // build notification message 132 | let message; 133 | if (req.fields.error_desc) { 134 | message = req.fields.error_desc 135 | } else { 136 | extraFields = JSON.parse(req.fields.extraP) 137 | message = 'Received '+ req.fields.currency + req.fields.amount + ' via ' + extraFields.bank_issuer 138 | } 139 | 140 | // push message to SSE client 141 | if (clients[clientIndex].hasOwnProperty('response')) 142 | clients[clientIndex].response.write(`data: ${message}\n\n`) 143 | // push message to Short Polling 144 | clients[clientIndex].message = message 145 | 146 | res.send('OK') 147 | }) 148 | 149 | // Start server 150 | app.listen(port, () => { 151 | console.log(`App listening on port ${port}`) 152 | }) --------------------------------------------------------------------------------