@sails-pay/bachs 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/adapter.js CHANGED
@@ -4,6 +4,7 @@ module.exports = {
4
4
  identity: 'sails-bachs',
5
5
  config: {},
6
6
  checkout: methods.checkout,
7
+ connect: methods.connect,
7
8
  customer: {
8
9
  portal: methods.customer.portal
9
10
  },
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Translates Bachs Connect responses into the provider-agnostic shapes
3
+ * returned by `sails.pay.connect`. Every adapter that implements Connect
4
+ * returns these same shapes, so application code never reads provider fields.
5
+ */
6
+
7
+ function capabilityStatus(capability) {
8
+ const status = capability && capability.status
9
+ if (status === 'active' || status === 'pending') return status
10
+ return 'inactive'
11
+ }
12
+
13
+ function toAccount(account, { email } = {}) {
14
+ const capabilities = {}
15
+ for (const [name, capability] of Object.entries(
16
+ (account && account.capabilities) || {}
17
+ )) {
18
+ capabilities[name] = capabilityStatus(capability)
19
+ }
20
+
21
+ return {
22
+ id: account.id,
23
+ email: account.contact_email || email || null,
24
+ name: account.display_name || account.name || null,
25
+ country: account.country || null,
26
+ capabilities,
27
+ requirements:
28
+ (account.requirements && account.requirements.currently_due) || [],
29
+ raw: account
30
+ }
31
+ }
32
+
33
+ function toLink(link) {
34
+ return {
35
+ url: link.url,
36
+ expiresAt: link.expires_at || null,
37
+ raw: link
38
+ }
39
+ }
40
+
41
+ function toTransfer(transfer) {
42
+ return {
43
+ id: transfer.id,
44
+ account: transfer.destination,
45
+ amount: transfer.amount,
46
+ currency: transfer.currency,
47
+ group: transfer.transfer_group || null,
48
+ status: transfer.status === 'paid' ? 'paid' : 'pending',
49
+ raw: transfer
50
+ }
51
+ }
52
+
53
+ function toBalances(response) {
54
+ return ((response && response.balances) || []).map((balance) => ({
55
+ currency: balance.currency,
56
+ available: balance.available_balance,
57
+ pending: balance.pending_balance
58
+ }))
59
+ }
60
+
61
+ function payoutStatus(status) {
62
+ if (status === 'completed') return 'paid'
63
+ if (status === 'failed') return 'failed'
64
+ return 'pending'
65
+ }
66
+
67
+ function toPayout(payout, { account }) {
68
+ return {
69
+ id: payout.id,
70
+ account,
71
+ amount: payout.amount,
72
+ currency: payout.currency,
73
+ fee: payout.fee || null,
74
+ destination: payout.destination || null,
75
+ status: payoutStatus(payout.status),
76
+ raw: payout
77
+ }
78
+ }
79
+
80
+ module.exports = {
81
+ toAccount,
82
+ toLink,
83
+ toTransfer,
84
+ toBalances,
85
+ toPayout
86
+ }
@@ -0,0 +1,77 @@
1
+ const fetch = require('../../../helpers/fetch')
2
+ const parameters = require('../../../helpers/parameters')
3
+ const { toAccount } = require('../../../helpers/connect')
4
+
5
+ module.exports = require('machine').build({
6
+ friendlyName: 'Create connected account',
7
+ description:
8
+ 'Creates a recipient account with its own balance that the platform can transfer to and that can withdraw.',
9
+ moreInfoUrl: 'https://docs.bachs.io/connect/guides/create-an-account',
10
+ inputs: {
11
+ apiKey: parameters.BACHS_API_KEY,
12
+ baseUrl: parameters.BACHS_BASE_URL,
13
+ email: {
14
+ type: 'string',
15
+ required: true,
16
+ isEmail: true,
17
+ description: 'Contact email of the person or business being onboarded.'
18
+ },
19
+ name: {
20
+ type: 'string',
21
+ description: 'Display name for the account.'
22
+ },
23
+ country: {
24
+ type: 'string',
25
+ defaultsTo: 'NG',
26
+ description: 'ISO 3166-1 alpha-2 country of the account.'
27
+ },
28
+ capabilities: {
29
+ type: ['string'],
30
+ defaultsTo: ['transfers', 'payouts'],
31
+ description: 'Capabilities to request, e.g. ["transfers", "payouts"].'
32
+ },
33
+ idempotencyKey: {
34
+ type: 'string',
35
+ description: 'Prevents a retry from creating a duplicate account.'
36
+ }
37
+ },
38
+ exits: {
39
+ success: {
40
+ description: 'The created account.',
41
+ outputVariableName: 'account',
42
+ outputType: 'ref'
43
+ },
44
+ couldNotCreateAccount: {
45
+ description: 'The account could not be created.',
46
+ outputVariableName: 'error',
47
+ outputType: 'ref'
48
+ }
49
+ },
50
+ fn: async function (inputs, exits) {
51
+ const adapterConfig = require('../../../adapter').config
52
+ const capabilities = {}
53
+ for (const capability of inputs.capabilities) {
54
+ capabilities[capability] = { requested: true }
55
+ }
56
+
57
+ try {
58
+ const account = await fetch('/accounts', {
59
+ method: 'POST',
60
+ apiKey: inputs.apiKey || adapterConfig.apiKey,
61
+ baseUrl: inputs.baseUrl || adapterConfig.baseUrl,
62
+ idempotencyKey: inputs.idempotencyKey,
63
+ body: {
64
+ contact_email: inputs.email,
65
+ ...(inputs.name && { display_name: inputs.name }),
66
+ country: inputs.country,
67
+ entity_type: 'individual',
68
+ configuration: { recipient: { capabilities } }
69
+ }
70
+ })
71
+
72
+ return exits.success(toAccount(account, { email: inputs.email }))
73
+ } catch (error) {
74
+ return exits.couldNotCreateAccount(error.bachs || error)
75
+ }
76
+ }
77
+ })
@@ -0,0 +1,49 @@
1
+ const fetch = require('../../../helpers/fetch')
2
+ const parameters = require('../../../helpers/parameters')
3
+ const { toAccount } = require('../../../helpers/connect')
4
+
5
+ module.exports = require('machine').build({
6
+ friendlyName: 'Get connected account',
7
+ description:
8
+ 'Retrieves a connected account with its capability statuses and currently due requirements.',
9
+ moreInfoUrl: 'https://docs.bachs.io/connect/accounts',
10
+ inputs: {
11
+ apiKey: parameters.BACHS_API_KEY,
12
+ baseUrl: parameters.BACHS_BASE_URL,
13
+ account: {
14
+ type: 'string',
15
+ required: true,
16
+ description: 'The connected account ID.'
17
+ }
18
+ },
19
+ exits: {
20
+ success: {
21
+ description: 'The connected account.',
22
+ outputVariableName: 'account',
23
+ outputType: 'ref'
24
+ },
25
+ couldNotGetAccount: {
26
+ description: 'The account could not be retrieved.',
27
+ outputVariableName: 'error',
28
+ outputType: 'ref'
29
+ }
30
+ },
31
+ fn: async function (inputs, exits) {
32
+ const adapterConfig = require('../../../adapter').config
33
+
34
+ try {
35
+ const account = await fetch(
36
+ `/accounts/${encodeURIComponent(inputs.account)}`,
37
+ {
38
+ method: 'GET',
39
+ apiKey: inputs.apiKey || adapterConfig.apiKey,
40
+ baseUrl: inputs.baseUrl || adapterConfig.baseUrl
41
+ }
42
+ )
43
+
44
+ return exits.success(toAccount(account))
45
+ } catch (error) {
46
+ return exits.couldNotGetAccount(error.bachs || error)
47
+ }
48
+ }
49
+ })
@@ -0,0 +1,71 @@
1
+ const fetch = require('../../../helpers/fetch')
2
+ const parameters = require('../../../helpers/parameters')
3
+ const { toLink } = require('../../../helpers/connect')
4
+
5
+ module.exports = require('machine').build({
6
+ friendlyName: 'Create connected account link',
7
+ description:
8
+ 'Creates a short-lived hosted link that walks a connected account through its outstanding requirements.',
9
+ moreInfoUrl: 'https://docs.bachs.io/connect/guides/hosted-onboarding',
10
+ inputs: {
11
+ apiKey: parameters.BACHS_API_KEY,
12
+ baseUrl: parameters.BACHS_BASE_URL,
13
+ account: {
14
+ type: 'string',
15
+ required: true,
16
+ description: 'The connected account ID.'
17
+ },
18
+ type: {
19
+ type: 'string',
20
+ isIn: ['onboarding', 'update'],
21
+ defaultsTo: 'onboarding',
22
+ description:
23
+ 'Use "onboarding" for new accounts and "update" to edit details.'
24
+ },
25
+ returnUrl: {
26
+ type: 'string',
27
+ required: true,
28
+ description: 'Where the account lands after leaving the hosted flow.'
29
+ },
30
+ refreshUrl: {
31
+ type: 'string',
32
+ required: true,
33
+ description: 'Where an expired or already used link sends the account.'
34
+ }
35
+ },
36
+ exits: {
37
+ success: {
38
+ description: 'The hosted link.',
39
+ outputVariableName: 'link',
40
+ outputType: 'ref'
41
+ },
42
+ couldNotCreateLink: {
43
+ description: 'The hosted link could not be created.',
44
+ outputVariableName: 'error',
45
+ outputType: 'ref'
46
+ }
47
+ },
48
+ fn: async function (inputs, exits) {
49
+ const adapterConfig = require('../../../adapter').config
50
+
51
+ try {
52
+ const link = await fetch(
53
+ `/accounts/${encodeURIComponent(inputs.account)}/account-links`,
54
+ {
55
+ method: 'POST',
56
+ apiKey: inputs.apiKey || adapterConfig.apiKey,
57
+ baseUrl: inputs.baseUrl || adapterConfig.baseUrl,
58
+ body: {
59
+ type: inputs.type,
60
+ refresh_url: inputs.refreshUrl,
61
+ return_url: inputs.returnUrl
62
+ }
63
+ }
64
+ )
65
+
66
+ return exits.success(toLink(link))
67
+ } catch (error) {
68
+ return exits.couldNotCreateLink(error.bachs || error)
69
+ }
70
+ }
71
+ })
@@ -0,0 +1,47 @@
1
+ const fetch = require('../../../helpers/fetch')
2
+ const parameters = require('../../../helpers/parameters')
3
+ const { toBalances } = require('../../../helpers/connect')
4
+
5
+ module.exports = require('machine').build({
6
+ friendlyName: 'Get connected account balance',
7
+ description:
8
+ 'Retrieves the available and pending balance of a connected account in every currency it holds.',
9
+ moreInfoUrl: 'https://docs.bachs.io/connect/balances',
10
+ inputs: {
11
+ apiKey: parameters.BACHS_API_KEY,
12
+ baseUrl: parameters.BACHS_BASE_URL,
13
+ account: {
14
+ type: 'string',
15
+ required: true,
16
+ description: 'The connected account ID.'
17
+ }
18
+ },
19
+ exits: {
20
+ success: {
21
+ description: 'One entry per currency.',
22
+ outputVariableName: 'balances',
23
+ outputType: 'ref'
24
+ },
25
+ couldNotGetBalance: {
26
+ description: 'The balance could not be retrieved.',
27
+ outputVariableName: 'error',
28
+ outputType: 'ref'
29
+ }
30
+ },
31
+ fn: async function (inputs, exits) {
32
+ const adapterConfig = require('../../../adapter').config
33
+
34
+ try {
35
+ const response = await fetch('/balances', {
36
+ method: 'GET',
37
+ apiKey: inputs.apiKey || adapterConfig.apiKey,
38
+ baseUrl: inputs.baseUrl || adapterConfig.baseUrl,
39
+ headers: { 'X-Account-Id': inputs.account }
40
+ })
41
+
42
+ return exits.success(toBalances(response))
43
+ } catch (error) {
44
+ return exits.couldNotGetBalance(error.bachs || error)
45
+ }
46
+ }
47
+ })
@@ -0,0 +1,114 @@
1
+ const fetch = require('../../../helpers/fetch')
2
+ const parameters = require('../../../helpers/parameters')
3
+ const { toPayout } = require('../../../helpers/connect')
4
+
5
+ module.exports = require('machine').build({
6
+ friendlyName: 'Create connected account payout',
7
+ description:
8
+ "Withdraws from a connected account's balance to one of its payout destinations, defaulting to the account's default destination for the currency.",
9
+ moreInfoUrl: 'https://docs.bachs.io/connect/payouts',
10
+ inputs: {
11
+ apiKey: parameters.BACHS_API_KEY,
12
+ baseUrl: parameters.BACHS_BASE_URL,
13
+ account: {
14
+ type: 'string',
15
+ required: true,
16
+ description: 'The connected account withdrawing.'
17
+ },
18
+ amount: {
19
+ type: 'string',
20
+ required: true,
21
+ description: 'Decimal amount the destination should receive.'
22
+ },
23
+ currency: {
24
+ type: 'string',
25
+ description:
26
+ 'ISO 4217 currency. Required when destination is omitted, so the default destination can be found.'
27
+ },
28
+ destination: {
29
+ type: 'string',
30
+ description: 'A payout destination ID. Omit to use the default one.'
31
+ },
32
+ reference: {
33
+ type: 'string',
34
+ maxLength: 128,
35
+ description: 'Your reference for the payout.'
36
+ },
37
+ idempotencyKey: {
38
+ type: 'string',
39
+ description: 'Prevents a retry from paying out twice.'
40
+ }
41
+ },
42
+ exits: {
43
+ success: {
44
+ description: 'The payout accepted for delivery.',
45
+ outputVariableName: 'payout',
46
+ outputType: 'ref'
47
+ },
48
+ noDestination: {
49
+ description:
50
+ 'No destination was given and the account has no usable destination for the currency.',
51
+ outputVariableName: 'error',
52
+ outputType: 'ref'
53
+ },
54
+ couldNotCreatePayout: {
55
+ description: 'The payout could not be created.',
56
+ outputVariableName: 'error',
57
+ outputType: 'ref'
58
+ }
59
+ },
60
+ fn: async function (inputs, exits) {
61
+ const adapterConfig = require('../../../adapter').config
62
+ const request = {
63
+ apiKey: inputs.apiKey || adapterConfig.apiKey,
64
+ baseUrl: inputs.baseUrl || adapterConfig.baseUrl,
65
+ headers: { 'X-Account-Id': inputs.account }
66
+ }
67
+
68
+ let destination = inputs.destination
69
+ if (!destination) {
70
+ if (!inputs.currency) {
71
+ return exits.noDestination({
72
+ message: 'Pass a destination, or a currency to use the default one.'
73
+ })
74
+ }
75
+
76
+ try {
77
+ const { destinations = [] } = await fetch(
78
+ `/payouts/destinations?currency=${encodeURIComponent(
79
+ inputs.currency
80
+ )}`,
81
+ { method: 'GET', ...request }
82
+ )
83
+ const usable = destinations.filter((candidate) => candidate.is_usable)
84
+ const chosen =
85
+ usable.find((candidate) => candidate.is_default) || usable[0]
86
+ if (!chosen) {
87
+ return exits.noDestination({
88
+ message: `The account has no usable ${inputs.currency} payout destination.`
89
+ })
90
+ }
91
+ destination = chosen.id
92
+ } catch (error) {
93
+ return exits.couldNotCreatePayout(error.bachs || error)
94
+ }
95
+ }
96
+
97
+ try {
98
+ const payout = await fetch('/payouts', {
99
+ method: 'POST',
100
+ ...request,
101
+ idempotencyKey: inputs.idempotencyKey || inputs.reference,
102
+ body: {
103
+ destination,
104
+ amount: inputs.amount,
105
+ ...(inputs.reference && { reference: inputs.reference })
106
+ }
107
+ })
108
+
109
+ return exits.success(toPayout(payout, { account: inputs.account }))
110
+ } catch (error) {
111
+ return exits.couldNotCreatePayout(error.bachs || error)
112
+ }
113
+ }
114
+ })
@@ -0,0 +1,71 @@
1
+ const fetch = require('../../../helpers/fetch')
2
+ const parameters = require('../../../helpers/parameters')
3
+ const { toTransfer } = require('../../../helpers/connect')
4
+
5
+ module.exports = require('machine').build({
6
+ friendlyName: 'Create transfer',
7
+ description:
8
+ "Moves funds from the platform balance into a connected account's balance.",
9
+ moreInfoUrl: 'https://docs.bachs.io/connect/transfers',
10
+ inputs: {
11
+ apiKey: parameters.BACHS_API_KEY,
12
+ baseUrl: parameters.BACHS_BASE_URL,
13
+ account: {
14
+ type: 'string',
15
+ required: true,
16
+ description: 'The connected account receiving the funds.'
17
+ },
18
+ amount: {
19
+ type: 'string',
20
+ required: true,
21
+ description: 'Decimal amount, e.g. "10000.00".'
22
+ },
23
+ currency: {
24
+ type: 'string',
25
+ required: true,
26
+ description: 'ISO 4217 currency, e.g. "NGN".'
27
+ },
28
+ group: {
29
+ type: 'string',
30
+ description: 'Groups related transfers, e.g. one payrun.'
31
+ },
32
+ idempotencyKey: {
33
+ type: 'string',
34
+ description: 'Prevents a retry from transferring twice.'
35
+ }
36
+ },
37
+ exits: {
38
+ success: {
39
+ description: 'The created transfer.',
40
+ outputVariableName: 'transfer',
41
+ outputType: 'ref'
42
+ },
43
+ couldNotCreateTransfer: {
44
+ description: 'The transfer could not be created.',
45
+ outputVariableName: 'error',
46
+ outputType: 'ref'
47
+ }
48
+ },
49
+ fn: async function (inputs, exits) {
50
+ const adapterConfig = require('../../../adapter').config
51
+
52
+ try {
53
+ const transfer = await fetch('/transfers', {
54
+ method: 'POST',
55
+ apiKey: inputs.apiKey || adapterConfig.apiKey,
56
+ baseUrl: inputs.baseUrl || adapterConfig.baseUrl,
57
+ idempotencyKey: inputs.idempotencyKey,
58
+ body: {
59
+ destination: inputs.account,
60
+ amount: inputs.amount,
61
+ currency: inputs.currency,
62
+ ...(inputs.group && { transfer_group: inputs.group })
63
+ }
64
+ })
65
+
66
+ return exits.success(toTransfer(transfer))
67
+ } catch (error) {
68
+ return exits.couldNotCreateTransfer(error.bachs || error)
69
+ }
70
+ }
71
+ })
package/machines/index.js CHANGED
@@ -4,6 +4,22 @@ checkout.get = require('./checkout/get')
4
4
 
5
5
  module.exports = {
6
6
  checkout,
7
+ connect: {
8
+ account: {
9
+ create: require('./connect/account/create'),
10
+ get: require('./connect/account/get'),
11
+ link: require('./connect/account/link')
12
+ },
13
+ transfer: {
14
+ create: require('./connect/transfer/create')
15
+ },
16
+ balance: {
17
+ get: require('./connect/balance/get')
18
+ },
19
+ payout: {
20
+ create: require('./connect/payout/create')
21
+ }
22
+ },
7
23
  customer: {
8
24
  portal: require('./customer/portal')
9
25
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sails-pay/bachs",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Bachs adapter for Sails Pay",
5
5
  "main": "adapter.js",
6
6
  "scripts": {
@@ -0,0 +1,390 @@
1
+ const { test, afterEach } = require('node:test')
2
+ const assert = require('node:assert/strict')
3
+ const adapter = require('../adapter')
4
+ const fetch = require('../helpers/fetch')
5
+
6
+ afterEach(() => {
7
+ adapter.config = {}
8
+ fetch.resetFetchImplementation()
9
+ })
10
+
11
+ function jsonResponse(body, status = 200) {
12
+ return {
13
+ ok: status >= 200 && status < 300,
14
+ status,
15
+ statusText: status >= 200 && status < 300 ? 'OK' : 'Request failed',
16
+ text: async () => JSON.stringify(body)
17
+ }
18
+ }
19
+
20
+ function recordCalls(responses) {
21
+ const calls = []
22
+ fetch.setFetchImplementation(async (url, options) => {
23
+ calls.push({ url, options })
24
+ const next = responses[calls.length - 1]
25
+ return jsonResponse(next.body, next.status)
26
+ })
27
+ return calls
28
+ }
29
+
30
+ test('adapter exposes exactly the Connect surface', () => {
31
+ assert.deepEqual(Object.keys(adapter.connect).sort(), [
32
+ 'account',
33
+ 'balance',
34
+ 'payout',
35
+ 'transfer'
36
+ ])
37
+ assert.deepEqual(Object.keys(adapter.connect.account).sort(), [
38
+ 'create',
39
+ 'get',
40
+ 'link'
41
+ ])
42
+ assert.deepEqual(Object.keys(adapter.connect.transfer), ['create'])
43
+ assert.deepEqual(Object.keys(adapter.connect.balance), ['get'])
44
+ assert.deepEqual(Object.keys(adapter.connect.payout), ['create'])
45
+ })
46
+
47
+ test('connect.account.create requests a recipient account and normalizes it', async () => {
48
+ const calls = recordCalls([
49
+ {
50
+ status: 201,
51
+ body: {
52
+ id: 'acct_123',
53
+ display_name: 'Ada Obi',
54
+ country: 'NG',
55
+ capabilities: {
56
+ transfers: { status: 'pending', requested: true },
57
+ payouts: { status: 'unrequested', requested: false }
58
+ },
59
+ requirements: { currently_due: ['payout_destination'] }
60
+ }
61
+ }
62
+ ])
63
+
64
+ const account = await adapter.connect.account.create({
65
+ apiKey: 'sk_sandbox_123',
66
+ email: 'ada@example.com',
67
+ name: 'Ada Obi',
68
+ idempotencyKey: 'maintainer-42'
69
+ })
70
+
71
+ assert.equal(calls[0].url, 'https://sandbox-api.bachs.io/v1/accounts')
72
+ assert.equal(calls[0].options.method, 'POST')
73
+ assert.equal(calls[0].options.headers['Idempotency-Key'], 'maintainer-42')
74
+ assert.equal(calls[0].options.headers['X-Account-Id'], undefined)
75
+ assert.deepEqual(JSON.parse(calls[0].options.body), {
76
+ contact_email: 'ada@example.com',
77
+ display_name: 'Ada Obi',
78
+ country: 'NG',
79
+ entity_type: 'individual',
80
+ configuration: {
81
+ recipient: {
82
+ capabilities: {
83
+ transfers: { requested: true },
84
+ payouts: { requested: true }
85
+ }
86
+ }
87
+ }
88
+ })
89
+
90
+ assert.equal(account.id, 'acct_123')
91
+ assert.equal(account.email, 'ada@example.com')
92
+ assert.equal(account.name, 'Ada Obi')
93
+ assert.equal(account.country, 'NG')
94
+ assert.deepEqual(account.capabilities, {
95
+ transfers: 'pending',
96
+ payouts: 'inactive'
97
+ })
98
+ assert.deepEqual(account.requirements, ['payout_destination'])
99
+ assert.equal(account.raw.id, 'acct_123')
100
+ })
101
+
102
+ test('connect.account.get reads the account by id', async () => {
103
+ const calls = recordCalls([
104
+ {
105
+ body: {
106
+ id: 'acct_1/2',
107
+ capabilities: { payouts: { status: 'active' } },
108
+ requirements: { currently_due: [] }
109
+ }
110
+ }
111
+ ])
112
+
113
+ const account = await adapter.connect.account.get({
114
+ apiKey: 'sk_sandbox_123',
115
+ account: 'acct_1/2'
116
+ })
117
+
118
+ assert.equal(
119
+ calls[0].url,
120
+ 'https://sandbox-api.bachs.io/v1/accounts/acct_1%2F2'
121
+ )
122
+ assert.equal(calls[0].options.method, 'GET')
123
+ assert.deepEqual(account.capabilities, { payouts: 'active' })
124
+ assert.deepEqual(account.requirements, [])
125
+ })
126
+
127
+ test('connect.account.link creates a hosted onboarding link', async () => {
128
+ const calls = recordCalls([
129
+ {
130
+ body: {
131
+ url: 'https://connect.bachs.io/l/abc',
132
+ expires_at: '2026-09-14T16:00:00Z'
133
+ }
134
+ }
135
+ ])
136
+
137
+ const link = await adapter.connect.account.link({
138
+ apiKey: 'sk_sandbox_123',
139
+ account: 'acct_123',
140
+ returnUrl: 'https://flossafrica.com/payouts/return',
141
+ refreshUrl: 'https://flossafrica.com/payouts/refresh'
142
+ })
143
+
144
+ assert.equal(
145
+ calls[0].url,
146
+ 'https://sandbox-api.bachs.io/v1/accounts/acct_123/account-links'
147
+ )
148
+ assert.deepEqual(JSON.parse(calls[0].options.body), {
149
+ type: 'onboarding',
150
+ refresh_url: 'https://flossafrica.com/payouts/refresh',
151
+ return_url: 'https://flossafrica.com/payouts/return'
152
+ })
153
+ assert.equal(link.url, 'https://connect.bachs.io/l/abc')
154
+ assert.equal(link.expiresAt, '2026-09-14T16:00:00Z')
155
+ })
156
+
157
+ test('connect.transfer.create moves platform funds to the account', async () => {
158
+ const calls = recordCalls([
159
+ {
160
+ status: 201,
161
+ body: {
162
+ id: 'tr_1',
163
+ destination: 'acct_123',
164
+ amount: '10000.00',
165
+ currency: 'NGN',
166
+ transfer_group: 'pool-2026-09',
167
+ status: 'paid'
168
+ }
169
+ }
170
+ ])
171
+
172
+ const transfer = await adapter.connect.transfer.create({
173
+ apiKey: 'sk_sandbox_123',
174
+ account: 'acct_123',
175
+ amount: '10000.00',
176
+ currency: 'NGN',
177
+ group: 'pool-2026-09',
178
+ idempotencyKey: 'pool-2026-09-acct_123'
179
+ })
180
+
181
+ assert.equal(calls[0].url, 'https://sandbox-api.bachs.io/v1/transfers')
182
+ assert.equal(calls[0].options.headers['X-Account-Id'], undefined)
183
+ assert.equal(
184
+ calls[0].options.headers['Idempotency-Key'],
185
+ 'pool-2026-09-acct_123'
186
+ )
187
+ assert.deepEqual(JSON.parse(calls[0].options.body), {
188
+ destination: 'acct_123',
189
+ amount: '10000.00',
190
+ currency: 'NGN',
191
+ transfer_group: 'pool-2026-09'
192
+ })
193
+ assert.deepEqual(
194
+ { ...transfer, raw: undefined },
195
+ {
196
+ id: 'tr_1',
197
+ account: 'acct_123',
198
+ amount: '10000.00',
199
+ currency: 'NGN',
200
+ group: 'pool-2026-09',
201
+ status: 'paid',
202
+ raw: undefined
203
+ }
204
+ )
205
+ })
206
+
207
+ test('connect.balance.get acts as the account and returns one entry per currency', async () => {
208
+ const calls = recordCalls([
209
+ {
210
+ body: {
211
+ account_id: 'acct_123',
212
+ balances: [
213
+ {
214
+ currency: 'NGN',
215
+ available_balance: '12000.00',
216
+ pending_balance: '0.00'
217
+ },
218
+ {
219
+ currency: 'USD',
220
+ available_balance: '5.00',
221
+ pending_balance: '1.00'
222
+ }
223
+ ],
224
+ total_balance_usd: '13.00'
225
+ }
226
+ }
227
+ ])
228
+
229
+ const balances = await adapter.connect.balance.get({
230
+ apiKey: 'sk_sandbox_123',
231
+ account: 'acct_123'
232
+ })
233
+
234
+ assert.equal(calls[0].url, 'https://sandbox-api.bachs.io/v1/balances')
235
+ assert.equal(calls[0].options.headers['X-Account-Id'], 'acct_123')
236
+ assert.deepEqual(balances, [
237
+ { currency: 'NGN', available: '12000.00', pending: '0.00' },
238
+ { currency: 'USD', available: '5.00', pending: '1.00' }
239
+ ])
240
+ })
241
+
242
+ test('connect.payout.create resolves the default destination for the currency', async () => {
243
+ const calls = recordCalls([
244
+ {
245
+ body: {
246
+ destinations: [
247
+ { id: 'pd_old', is_usable: true, is_default: false },
248
+ { id: 'pd_broken', is_usable: false, is_default: false },
249
+ { id: 'pd_default', is_usable: true, is_default: true }
250
+ ],
251
+ total: 3,
252
+ limit: 20,
253
+ offset: 0
254
+ }
255
+ },
256
+ {
257
+ body: {
258
+ id: 'pay_1',
259
+ status: 'processing',
260
+ amount: '9000.00',
261
+ currency: 'NGN',
262
+ fee: '50.00',
263
+ total_debited: '9050.00',
264
+ destination: 'pd_default'
265
+ }
266
+ }
267
+ ])
268
+
269
+ const payout = await adapter.connect.payout.create({
270
+ apiKey: 'sk_sandbox_123',
271
+ account: 'acct_123',
272
+ amount: '9000.00',
273
+ currency: 'NGN',
274
+ reference: 'withdrawal-1',
275
+ idempotencyKey: 'withdrawal-1-attempt-1'
276
+ })
277
+
278
+ assert.equal(
279
+ calls[0].url,
280
+ 'https://sandbox-api.bachs.io/v1/payouts/destinations?currency=NGN'
281
+ )
282
+ assert.equal(calls[0].options.headers['X-Account-Id'], 'acct_123')
283
+ assert.equal(calls[1].url, 'https://sandbox-api.bachs.io/v1/payouts')
284
+ assert.equal(calls[1].options.headers['X-Account-Id'], 'acct_123')
285
+ assert.equal(
286
+ calls[1].options.headers['Idempotency-Key'],
287
+ 'withdrawal-1-attempt-1'
288
+ )
289
+ assert.deepEqual(JSON.parse(calls[1].options.body), {
290
+ destination: 'pd_default',
291
+ amount: '9000.00',
292
+ reference: 'withdrawal-1'
293
+ })
294
+ assert.deepEqual(
295
+ { ...payout, raw: undefined },
296
+ {
297
+ id: 'pay_1',
298
+ account: 'acct_123',
299
+ amount: '9000.00',
300
+ currency: 'NGN',
301
+ fee: '50.00',
302
+ destination: 'pd_default',
303
+ status: 'pending',
304
+ raw: undefined
305
+ }
306
+ )
307
+ })
308
+
309
+ test('connect.payout.create uses an explicit destination without a lookup', async () => {
310
+ const calls = recordCalls([
311
+ {
312
+ body: {
313
+ id: 'pay_2',
314
+ status: 'completed',
315
+ amount: '100.00',
316
+ currency: 'NGN',
317
+ destination: 'pd_1'
318
+ }
319
+ }
320
+ ])
321
+
322
+ const payout = await adapter.connect.payout.create({
323
+ apiKey: 'sk_sandbox_123',
324
+ account: 'acct_123',
325
+ amount: '100.00',
326
+ destination: 'pd_1',
327
+ idempotencyKey: 'withdrawal-2'
328
+ })
329
+
330
+ assert.equal(calls.length, 1)
331
+ assert.equal(payout.status, 'paid')
332
+ })
333
+
334
+ test('connect.payout.create exits noDestination when nothing is usable', async () => {
335
+ recordCalls([
336
+ {
337
+ body: {
338
+ destinations: [{ id: 'pd_review', is_usable: false }],
339
+ total: 1,
340
+ limit: 20,
341
+ offset: 0
342
+ }
343
+ }
344
+ ])
345
+
346
+ await assert.rejects(
347
+ adapter.connect.payout.create({
348
+ apiKey: 'sk_sandbox_123',
349
+ account: 'acct_123',
350
+ amount: '100.00',
351
+ currency: 'NGN'
352
+ }),
353
+ (error) => error.exit === 'noDestination'
354
+ )
355
+ })
356
+
357
+ test('connect.payout.create exits noDestination without a destination or currency', async () => {
358
+ const calls = recordCalls([])
359
+
360
+ await assert.rejects(
361
+ adapter.connect.payout.create({
362
+ apiKey: 'sk_sandbox_123',
363
+ account: 'acct_123',
364
+ amount: '100.00'
365
+ }),
366
+ (error) => error.exit === 'noDestination'
367
+ )
368
+ assert.equal(calls.length, 0)
369
+ })
370
+
371
+ test('Connect errors surface the normalized Bachs error', async () => {
372
+ recordCalls([
373
+ {
374
+ status: 400,
375
+ body: { error_code: 'INSUFFICIENT_BALANCE', detail: 'Not enough funds' }
376
+ }
377
+ ])
378
+
379
+ await assert.rejects(
380
+ adapter.connect.transfer.create({
381
+ apiKey: 'sk_sandbox_123',
382
+ account: 'acct_123',
383
+ amount: '1.00',
384
+ currency: 'NGN'
385
+ }),
386
+ (error) =>
387
+ error.exit === 'couldNotCreateTransfer' &&
388
+ error.raw.code === 'INSUFFICIENT_BALANCE'
389
+ )
390
+ })