Skip to content
This repository was archived by the owner on May 11, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion modules/investec.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ class Investec {
}

async get(path) {
await axios.get(path, this.axiosConfig()).catch(logError)
return await axios.get(path, this.axiosConfig()).catch(logError)
}

async getWithAuth(path) {
Expand Down
51 changes: 48 additions & 3 deletions modules/investec_sandbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,22 +42,67 @@ const transactions = [
}
]

const balance = {
accountId,
currentBalance: 125500,
availableBalance: 125500,
currency: 'ZAR'
}

function response(data, status = 200) {
return { status, data }
}

function sandboxUrl(path) {
return new URL(path, 'https://sandbox.banking.make')
}

function matchesDateRange(transaction, fromDate, toDate) {
const date = transaction.transactionDate || transaction.postingDate
return (!fromDate || date >= fromDate) && (!toDate || date <= toDate)
}

function filterTransactions(requestedAccountId, params) {
const fromDate = params.get('fromDate')
const toDate = params.get('toDate')
const transactionType = params.get('transactionType')

return transactions.filter((transaction) => {
return transaction.accountId === requestedAccountId &&
matchesDateRange(transaction, fromDate, toDate) &&
(!transactionType || transaction.transactionType === transactionType)
})
}

class InvestecSandbox {
async getWithAuth(path) {
if (path === '/za/pb/v1/accounts') {
const url = sandboxUrl(path)

if (url.pathname === '/za/pb/v1/accounts') {
return response({ data: { accounts } })
}

const transactionsMatch = path.match(/^\/za\/pb\/v1\/accounts\/([^/]+)\/transactions/)
const balanceMatch = url.pathname.match(/^\/za\/pb\/v1\/accounts\/([^/]+)\/balance$/)
if (balanceMatch) {
const requestedAccountId = balanceMatch[1]
if (requestedAccountId !== accountId) {
return response({
data: {
message: 'Sandbox account not found',
accountId: requestedAccountId
}
}, 404)
}

return response({ data: balance })
}

const transactionsMatch = url.pathname.match(/^\/za\/pb\/v1\/accounts\/([^/]+)\/transactions$/)
if (transactionsMatch) {
const requestedAccountId = transactionsMatch[1]
return response({
data: {
transactions: transactions.filter(transaction => transaction.accountId === requestedAccountId)
transactions: filterTransactions(requestedAccountId, url.searchParams)
}
})
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "node --test"
},
"keywords": [],
"author": "",
Expand Down
12 changes: 12 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ __Where do I get support?__
- `routes/investec/auth.js` contains middleware to extract partition information from API keys, if the requester is using partitioned access and `routes/investec/card_partitions.js` contains config for those partitions
- `routes/investec/special.js` extends Investec API base functionality by providing an abstraction layer for card control, handling card events, and simplifying the transfer endpoint

#### Sandbox mode

Use `SANDBOX` Basic auth credentials to exercise Investec account endpoints without calling the live Investec API. This lets clients switch between live and sandbox mode by changing credentials while keeping the same API paths.

Sandbox mode currently supports:

- `GET /investec/za/pb/v1/accounts`
- `GET /investec/za/pb/v1/accounts/sandbox-account-1/balance`
- `GET /investec/za/pb/v1/accounts/sandbox-account-1/transactions`

The transactions endpoint also accepts the optional `fromDate`, `toDate`, and `transactionType` query parameters.


### RootCode card control
- `modules/root_code_card_policy.js` converts simple config flags into a JS bundle that can be compiled as RootCode onto a supported card
Expand Down
10 changes: 7 additions & 3 deletions routes/investec.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ router.get('/za/v1/cards', async (_req, _res) => {
const investec = new Investec(_req.currentUser.token)
const response = await investec.getWithAuth(`/za/v1/cards`)

if ((response.status || 200) >= 400) {
return _res.status(response.status).json(response.data)
}

let cards = response.data.data.cards

// filter out any cards that aren't granted to this partition,
Expand All @@ -31,15 +35,15 @@ router.get('/za/v1/cards', async (_req, _res) => {
router.get('/*', async (_req, _res) => {
const investec = new Investec(_req.currentUser.token)
const response = await investec.getWithAuth(_req.url)
_res.json(response.data)
_res.status(response.status || 200).json(response.data)
})

// proxies any POST request to the investec adapter
// with a valid bearer token added in
router.post('/*', async (_req, _res) => {
const investec = new Investec(_req.currentUser.token)
const response = await investec.postWithAuth(_req.url, _req.body)
_res.json(response.data)
_res.status(response.status || 200).json(response.data)
})

module.exports = router
module.exports = router
21 changes: 18 additions & 3 deletions routes/investec/auth.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,37 @@
function sandboxUser() {
return { token: 'SANDBOX', username: 'SANDBOX', partition: undefined }
}

function splitPartitionFromToken(partitionedToken) {
// pulls out an optional partition (subfolder) from API credentials
// which is used when filtering Investec API responses to show only
// certain cards

const partitionedCredentials = new Buffer(partitionedToken, 'base64').toString()
if (partitionedToken === 'SANDBOX') {
return sandboxUser()
}

const partitionedCredentials = Buffer.from(partitionedToken, 'base64').toString()
if (partitionedCredentials === 'SANDBOX') {
return sandboxUser()
}

const [partitionedUsername, password] = partitionedCredentials.split(":")
const [username, partition] = partitionedUsername.split("/")
const token = (new Buffer.from(`${username}:${password}`)).toString('base64')
const token = Buffer.from(`${username}:${password}`).toString('base64')
return { token, username, partition }
}

function getAuth(_req, _res, next) {
if (!_req.headers.authorization) {
return next()
}

let [authType, partitionedToken] = _req.headers.authorization.split(" ")

if (authType == "Basic") {
let { token, username, partition } = splitPartitionFromToken(partitionedToken)
_req.currentUser = { username, partition, token }
console.log(_req.currentUser)
}

if (authType == "RootCard") {
Expand Down
93 changes: 93 additions & 0 deletions test/investec_sandbox.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const express = require('express')

const Investec = require('../modules/investec')
const getAuth = require('../routes/investec/auth')
const investecRouter = require('../routes/investec')

function currentUserForAuthorization(authorization) {
const req = { headers: { authorization } }
getAuth(req, {}, () => {})
return req.currentUser
}

test('auth middleware accepts raw Basic SANDBOX credentials', () => {
assert.deepEqual(currentUserForAuthorization('Basic SANDBOX'), {
token: 'SANDBOX',
username: 'SANDBOX',
partition: undefined
})
})

test('Investec adapter returns sandbox accounts, balance, and transactions', async () => {
const investec = new Investec('SANDBOX')

const accountsResponse = await investec.getWithAuth('/za/pb/v1/accounts')
assert.equal(accountsResponse.status, 200)
assert.equal(accountsResponse.data.data.accounts.length, 1)

const accountId = accountsResponse.data.data.accounts[0].accountId
const balanceResponse = await investec.getWithAuth(`/za/pb/v1/accounts/${accountId}/balance`)
assert.equal(balanceResponse.status, 200)
assert.deepEqual(balanceResponse.data.data, {
accountId,
currentBalance: 125500,
availableBalance: 125500,
currency: 'ZAR'
})

const transactionsResponse = await investec.getWithAuth(`/za/pb/v1/accounts/${accountId}/transactions`)
assert.equal(transactionsResponse.status, 200)
assert.equal(transactionsResponse.data.data.transactions.length, 2)
assert.ok(transactionsResponse.data.data.transactions.every((transaction) => transaction.accountId === accountId))

const filteredResponse = await investec.getWithAuth(
`/za/pb/v1/accounts/${accountId}/transactions?fromDate=2024-01-10&transactionType=CardPurchases`
)
assert.equal(filteredResponse.status, 200)
assert.deepEqual(
filteredResponse.data.data.transactions.map((transaction) => transaction.description),
['SANDBOX COFFEE SHOP']
)
})

test('Investec proxy preserves sandbox response statuses', async (t) => {
const app = express()
app.use(express.json())
app.use('/investec', investecRouter)

const server = app.listen(0)
t.after(() => server.close())

const { port } = server.address()
const baseUrl = `http://127.0.0.1:${port}/investec`

const accountsResponse = await fetch(`${baseUrl}/za/pb/v1/accounts`, {
headers: { authorization: 'Basic SANDBOX' }
})
assert.equal(accountsResponse.status, 200)
assert.equal((await accountsResponse.json()).data.accounts.length, 1)

const balanceResponse = await fetch(`${baseUrl}/za/pb/v1/accounts/sandbox-account-1/balance`, {
headers: { authorization: 'Basic SANDBOX' }
})
assert.equal(balanceResponse.status, 200)
assert.equal((await balanceResponse.json()).data.currency, 'ZAR')

const filteredTransactionsResponse = await fetch(
`${baseUrl}/za/pb/v1/accounts/sandbox-account-1/transactions?fromDate=2024-01-10&transactionType=CardPurchases`,
{ headers: { authorization: 'Basic SANDBOX' } }
)
assert.equal(filteredTransactionsResponse.status, 200)
assert.deepEqual(
(await filteredTransactionsResponse.json()).data.transactions.map((transaction) => transaction.description),
['SANDBOX COFFEE SHOP']
)

const missingResponse = await fetch(`${baseUrl}/za/pb/v1/not-implemented`, {
headers: { authorization: 'Basic SANDBOX' }
})
assert.equal(missingResponse.status, 404)
assert.equal((await missingResponse.json()).data.path, '/za/pb/v1/not-implemented')
})