Custom payment integration (BPOINT example)
Storeganise allows not to integrate with any payment platform.
The only requirement is to develop a middleware responding to the following paths:
POST Path | Body | Description |
---|---|---|
/payment-methods |
{ businessCode, userId } | Use to list a user's active payment methods |
/delete-account |
{ businessCode, userId } | Used to delete an account when a Storeganise user is deleted (optional) |
/charge |
{ businessCode, userId, amount } | Used to charge or refund (if amount is negative) a user, amount is in cents |
/ |
{ businessCode, userId } | Used to display the checkout view where user enters his payment details |
Here's the full request handler:
import crypto from 'crypto'; export function _createApi(businessCode) { if (!businessCode) throw Object.assign(new Error('missing businessCode'), { status: 400 }); const apiKey = process.env[`API_KEY_${businessCode}`]; if (!apiKey) throw Object.assign(new Error(`missing API_KEY for ${businessCode}`), { status: 400 }); const sgApiUrl = `https://${businessCode}.storeganise.com/api`; return function fetchSg(path, { method, body, params } = {}) { return fetch(`${sgApiUrl}/v1/admin/${path}` + (params ? '?' + Object.entries(params || {}).map(([k, v]) => [k, v && encodeURIComponent(v)].filter(x => x != null).join('=')).join('&') : ''), { method, headers: { Authorization: `ApiKey ${apiKey}`, ...body && { 'Content-Type': 'application/json' }, }, body: body && JSON.stringify(body), }) .then(async r => { const data = await r.json().catch(() => ({})); if (r.ok) return data; throw Object.assign(new Error('sg'), { status: r.status, ...data.error }); }); }; } export function _createBpoint(business) { if (!business.customFields.bpoint_username) throw Object.assign(new Error(`missing business custom fields for bpoint`), { status: 400 }); return function fetchBpoint(path, { method = 'GET', body } = {}) { return fetch('https://www.bpoint.com.au/rest/v5/' + path, { method, headers: { Authorization: `Basic ${Buffer.from(`${business.customFields.bpoint_username}|${business.customFields.bpoint_merchantId}:${business.customFields.bpoint_password}`).toString('base64')}`, 'Content-Type': 'application/json' }, body: body && JSON.stringify(body) }) .then(async r => { if (r.ok) return r.json().catch(() => null); const text = await r.text(); throw new Error(`Bpoint error: ${text}`); }); } } export default async (req, res) => { try { const { type, returnUrl } = req.query || {}; if (req.method !== 'POST') { return res.status(404).send(); } const { businessCode, userId, amount } = req.body; const fetchSg = _createApi(businessCode); const business = await fetchSg('settings?include=customFields'); const fetchBpoint = _createBpoint(business); const currency = business.customFields.bpoint_currency || 'AUD'; switch (req.path) { case '/payment-methods': { const user = await fetchSg(`users/${userId}?include=billing`); if (!user.billing.id) return res.json([]); try { const data = await fetchBpoint(`tokens/${user.billing.id}`); return res.json([data.paymentMethod]); } catch (err) { return res.status(400).json({ message: err.message }); } } case '/delete-account': { const user = await fetchSg(`users/${userId}?include=billing`); if (user.billing.id) { await fetchBpoint(`tokens/${user.billing.id}`, { method: 'DELETE' }); } return res.status(user.billing.id ? 200 : 204).send(); } case '/charge': { const user = await fetchSg(`users/${userId}?include=billing`); if (!user.billing.id) { return res.status(204).send(); } if (amount < 0) { // ... refund logic } const { authkey } = await fetchBpoint('txns/authkeys', { method: 'POST' }); await fetchBpoint(`txns/authkeys/${authkey}/payment-method`, { method: 'PUT', body: { token: user.billing.id } }); await fetchBpoint(`txns/authkeys/${authkey}/txn-details`, { method: 'PUT', body: { action: 'Payment', type: 'Internet', subType: 'Single', amount, // in cents crn1: user.email, crn2: user.id, crn3: req.body.invoiceId, currency, emailAddress: user.email, testMode: business.customFields.bpoint_testMode ?? false // false in prod } }); const r = await fetchBpoint(`txns/authkeys/${authkey}/process`, { method: 'POST', body: { webhook: { url: `https://storeganise.npkn.net/bpoint-test/webhook?businessCode=${businessCode}`, version: '5', } } }); return res.send({ id: r.txn.txnNumber, amount: r.txn.amount, currency: r.txn.currency, status: r.txn.responseCode === '0' ? 'succeeded' : r.txn.bankResponseCode === '09' ? 'processing' : 'failed', bankResponseCode: r.txn.bankResponseCode, responseText: r.txn.responseText, paymentMethod: r.txn.paymentMethod, receipt: r.txn.receiptNumber, isTest: r.txn.isTestTxn, }); } case '/setup': { const { authkey } = req.body; const data = await fetchBpoint(`tokens/authkeys/${authkey}/process`, { method: 'POST', body: { webhook: { url: `https://storeganise.npkn.net/bpoint-test/webhook?businessCode=${businessCode}`, version: '5', } } }); if (data.token.crn2 !== userId) throw new Error('invalid crn2'); const user = await fetchSg(`users/${userId}?include=billing`); if (user.billing.id) { await fetchBpoint(`tokens/${user.billing.id}`, { method: 'DELETE' }); } const r = await fetchSg(`users/${userId}`, { method: 'PUT', body: { billing: { id: data.token.token, type: 'custom' } } }); res.set({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'OPTIONS, POST, GET', 'Access-Control-Max-Age': 2592000, }); return res.json({ ok: true }); } case '/': { const { authkey } = await fetchBpoint('tokens/authkeys', { method: 'POST' }); const user = await fetchSg(`users/${userId}?include=billing`); const r = await fetchBpoint(`tokens/authkeys/${authkey}/token-details`, { method: 'PUT', body: { crn1: user.email, crn2: user.id, EmailAddress: user.email, } }); return res.render('checkout', { business, authkey, type, userId, businessCode, returnUrl }); } default: return res.status(404).send(`Unknown path ${req.method} ${path}`); } } catch (err) { console.log(err); res.status(400).send(err.message); } }
The checkout view:
<html> <head> <title>{business.companyName} - Payment</title> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <link href="https://www.bpoint.com.au/rest/clientscripts/api.css" rel="stylesheet" /> </head> <body> <h1 style="font-family:sans-serif; text-align:center; margin-bottom:2em">Pay with <a href="https://www.bpoint.com.au/" target="_blank">BPOINT</a></h1> <div id="paymentMethodForm" data-authkey="{authkey}"></div> <script src="https://www.bpoint.com.au/rest/clientscripts/api.js"></script> <script> BPOINT.token.authkey.setupPaymentMethodForm(document.getElementById('paymentMethodForm').dataset.authkey, { appendToElementId: "paymentMethodForm", bank: { enabled: {type === 'bank'}, bsb: { label: 'BSB' }, account: { label: 'Account number' }, name: { label: 'Account name' } }, card: { enabled: {type !== 'bank'}, number: { label: "Card number" }, expiry: { label: "Expiry" }, cvn: { label: "Cvn" }, name: { label: "Name", hide: false } }, clientsideValidation: true, callback: function (code, data) { if (code === "success") { return fetch('https://storeganise.npkn.net/bpoint-test/setup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ authkey: '{authkey}', businessCode: '{businessCode}', userId: '{userId}' }), }) .then(async r => { submitBtn.disabled = false; if (!r.ok) return alert(await r.text()); location.href = '{returnUrl}'; }); } } }); </script> <style> .bpoint-btn:disabled { filter: opacity(.5); } </style> </body> </html>