@sails-pay/bachs 0.0.3 → 0.0.5
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 +1 -0
- package/helpers/connect.js +86 -0
- package/helpers/payloads.js +5 -1
- package/helpers/validate-checkout-connect.js +54 -0
- package/machines/checkout.js +16 -0
- package/machines/connect/account/create.js +77 -0
- package/machines/connect/account/get.js +49 -0
- package/machines/connect/account/link.js +71 -0
- package/machines/connect/balance/get.js +47 -0
- package/machines/connect/payout/create.js +114 -0
- package/machines/connect/transfer/create.js +71 -0
- package/machines/index.js +16 -0
- package/package.json +1 -1
- package/test/checkout.test.js +156 -0
- package/test/connect.test.js +390 -0
package/adapter.js
CHANGED
|
@@ -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
|
+
}
|
package/helpers/payloads.js
CHANGED
|
@@ -81,7 +81,11 @@ function buildCheckoutSessionPayload(inputs, adapterConfig = {}) {
|
|
|
81
81
|
return_url: returnUrl,
|
|
82
82
|
cancel_url: inputs.cancelUrl || adapterConfig.cancelUrl,
|
|
83
83
|
reference: inputs.reference,
|
|
84
|
-
metadata: inputs.metadata || checkoutData.custom
|
|
84
|
+
metadata: inputs.metadata || checkoutData.custom,
|
|
85
|
+
transfer_data: inputs.connect && {
|
|
86
|
+
destination: inputs.connect.destination
|
|
87
|
+
},
|
|
88
|
+
platform_fee: inputs.connect && inputs.connect.platformFee
|
|
85
89
|
})
|
|
86
90
|
}
|
|
87
91
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const allowedFields = new Set(['destination', 'platformFee'])
|
|
2
|
+
const decimalStringPattern = /^\d+(?:\.\d+)?$/
|
|
3
|
+
|
|
4
|
+
function invalid(field, message) {
|
|
5
|
+
return {
|
|
6
|
+
field,
|
|
7
|
+
message: `${field} ${message}`
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Validates the `connect` checkout input that splits a checkout between a
|
|
13
|
+
* connected account and the platform. Returns an error object, or null.
|
|
14
|
+
*/
|
|
15
|
+
function validateCheckoutConnect(connect, { isCheckoutSession }) {
|
|
16
|
+
if (!connect || typeof connect !== 'object' || Array.isArray(connect)) {
|
|
17
|
+
return invalid('connect', 'must be an object.')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!isCheckoutSession) {
|
|
21
|
+
return invalid(
|
|
22
|
+
'connect',
|
|
23
|
+
'is only supported on product checkout sessions (items or productCollectionId).'
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const field of Object.keys(connect)) {
|
|
28
|
+
if (!allowedFields.has(field)) {
|
|
29
|
+
return invalid(
|
|
30
|
+
`connect.${field}`,
|
|
31
|
+
'is not supported. Use connect.destination and connect.platformFee.'
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (typeof connect.destination !== 'string' || !connect.destination) {
|
|
37
|
+
return invalid('connect.destination', 'must be a connected account ID.')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (
|
|
41
|
+
typeof connect.platformFee !== 'string' ||
|
|
42
|
+
!decimalStringPattern.test(connect.platformFee) ||
|
|
43
|
+
Number(connect.platformFee) <= 0
|
|
44
|
+
) {
|
|
45
|
+
return invalid(
|
|
46
|
+
'connect.platformFee',
|
|
47
|
+
'must be a positive decimal string, e.g. "500.00".'
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = validateCheckoutConnect
|
package/machines/checkout.js
CHANGED
|
@@ -4,6 +4,7 @@ const {
|
|
|
4
4
|
buildPureCheckoutPayload
|
|
5
5
|
} = require('../helpers/payloads')
|
|
6
6
|
const validateCheckoutItems = require('../helpers/validate-checkout-items')
|
|
7
|
+
const validateCheckoutConnect = require('../helpers/validate-checkout-connect')
|
|
7
8
|
const parameters = require('../helpers/parameters')
|
|
8
9
|
|
|
9
10
|
module.exports = require('machine').build({
|
|
@@ -116,6 +117,11 @@ module.exports = require('machine').build({
|
|
|
116
117
|
simulatedOutcome: {
|
|
117
118
|
type: 'string',
|
|
118
119
|
description: 'Sandbox-only forced outcome.'
|
|
120
|
+
},
|
|
121
|
+
connect: {
|
|
122
|
+
type: 'ref',
|
|
123
|
+
description:
|
|
124
|
+
'Splits a checkout session with a connected account: { destination, platformFee }. The account receives the rest of the sale when it settles.'
|
|
119
125
|
}
|
|
120
126
|
},
|
|
121
127
|
exits: {
|
|
@@ -158,6 +164,16 @@ module.exports = require('machine').build({
|
|
|
158
164
|
}
|
|
159
165
|
}
|
|
160
166
|
|
|
167
|
+
if (inputs.connect !== undefined) {
|
|
168
|
+
const connectError = validateCheckoutConnect(inputs.connect, {
|
|
169
|
+
isCheckoutSession: shouldUseCheckoutSession
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
if (connectError) {
|
|
173
|
+
return exits.invalidRequest(connectError)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
161
177
|
const path = shouldUseCheckoutSession ? '/checkout-sessions' : '/checkouts'
|
|
162
178
|
const payload = shouldUseCheckoutSession
|
|
163
179
|
? buildCheckoutSessionPayload(inputs, adapterConfig)
|
|
@@ -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
package/test/checkout.test.js
CHANGED
|
@@ -363,3 +363,159 @@ test('adapter exposes checkout.get as the uniform checkout lookup API', async ()
|
|
|
363
363
|
)
|
|
364
364
|
assert.equal(calls[0].options.method, 'GET')
|
|
365
365
|
})
|
|
366
|
+
|
|
367
|
+
test('checkout splits a checkout session with a connected account', async () => {
|
|
368
|
+
const calls = []
|
|
369
|
+
|
|
370
|
+
fetch.setFetchImplementation(async (url, options) => {
|
|
371
|
+
calls.push({ url, options })
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
ok: true,
|
|
375
|
+
status: 201,
|
|
376
|
+
statusText: 'Created',
|
|
377
|
+
text: async () =>
|
|
378
|
+
JSON.stringify({
|
|
379
|
+
checkout_id: 'chk_split',
|
|
380
|
+
checkout_url: 'https://pay.bachs.io/c/split'
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
const checkoutUrl = await checkout({
|
|
386
|
+
apiKey: 'sk_sandbox_123',
|
|
387
|
+
items: [{ product: 'prod_abc123', amount: '10500.00' }],
|
|
388
|
+
reference: 'sponsorship_1',
|
|
389
|
+
connect: {
|
|
390
|
+
destination: 'acct_123',
|
|
391
|
+
platformFee: '500.00'
|
|
392
|
+
}
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
assert.equal(checkoutUrl, 'https://pay.bachs.io/c/split')
|
|
396
|
+
assert.equal(
|
|
397
|
+
calls[0].url,
|
|
398
|
+
'https://sandbox-api.bachs.io/v1/checkout-sessions'
|
|
399
|
+
)
|
|
400
|
+
assert.equal(calls[0].options.headers['X-Account-Id'], undefined)
|
|
401
|
+
|
|
402
|
+
const body = JSON.parse(calls[0].options.body)
|
|
403
|
+
assert.deepEqual(body.transfer_data, { destination: 'acct_123' })
|
|
404
|
+
assert.equal(body.platform_fee, '500.00')
|
|
405
|
+
assert.equal(body.connect, undefined)
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
test('checkout without connect sends no split fields', async () => {
|
|
409
|
+
const calls = []
|
|
410
|
+
|
|
411
|
+
fetch.setFetchImplementation(async (url, options) => {
|
|
412
|
+
calls.push({ url, options })
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
ok: true,
|
|
416
|
+
status: 201,
|
|
417
|
+
statusText: 'Created',
|
|
418
|
+
text: async () =>
|
|
419
|
+
JSON.stringify({ checkout_url: 'https://pay.bachs.io/c/plain' })
|
|
420
|
+
}
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
await checkout({
|
|
424
|
+
apiKey: 'sk_sandbox_123',
|
|
425
|
+
items: [{ product: 'prod_abc123' }]
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
const body = JSON.parse(calls[0].options.body)
|
|
429
|
+
assert.equal('transfer_data' in body, false)
|
|
430
|
+
assert.equal('platform_fee' in body, false)
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
const invalidConnectCases = [
|
|
434
|
+
{
|
|
435
|
+
name: 'connect that is not an object',
|
|
436
|
+
inputs: { items: [{ product: 'prod_abc123' }], connect: 'acct_123' },
|
|
437
|
+
field: 'connect',
|
|
438
|
+
message: /must be an object/
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
name: 'connect on a pure checkout',
|
|
442
|
+
inputs: {
|
|
443
|
+
amount: '10500.00',
|
|
444
|
+
currency: 'NGN',
|
|
445
|
+
connect: { destination: 'acct_123', platformFee: '500.00' }
|
|
446
|
+
},
|
|
447
|
+
field: 'connect',
|
|
448
|
+
message: /only supported on product checkout sessions/
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
name: 'connect without a destination',
|
|
452
|
+
inputs: {
|
|
453
|
+
items: [{ product: 'prod_abc123' }],
|
|
454
|
+
connect: { platformFee: '500.00' }
|
|
455
|
+
},
|
|
456
|
+
field: 'connect.destination',
|
|
457
|
+
message: /connected account ID/
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
name: 'connect without a platform fee',
|
|
461
|
+
inputs: {
|
|
462
|
+
items: [{ product: 'prod_abc123' }],
|
|
463
|
+
connect: { destination: 'acct_123' }
|
|
464
|
+
},
|
|
465
|
+
field: 'connect.platformFee',
|
|
466
|
+
message: /positive decimal string/
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
name: 'a numeric platform fee',
|
|
470
|
+
inputs: {
|
|
471
|
+
items: [{ product: 'prod_abc123' }],
|
|
472
|
+
connect: { destination: 'acct_123', platformFee: 500 }
|
|
473
|
+
},
|
|
474
|
+
field: 'connect.platformFee',
|
|
475
|
+
message: /positive decimal string/
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
name: 'a zero platform fee',
|
|
479
|
+
inputs: {
|
|
480
|
+
items: [{ product: 'prod_abc123' }],
|
|
481
|
+
connect: { destination: 'acct_123', platformFee: '0.00' }
|
|
482
|
+
},
|
|
483
|
+
field: 'connect.platformFee',
|
|
484
|
+
message: /positive decimal string/
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
name: 'an unknown connect field',
|
|
488
|
+
inputs: {
|
|
489
|
+
items: [{ product: 'prod_abc123' }],
|
|
490
|
+
connect: {
|
|
491
|
+
destination: 'acct_123',
|
|
492
|
+
platformFee: '500.00',
|
|
493
|
+
amount: '10000.00'
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
field: 'connect.amount',
|
|
497
|
+
message: /is not supported/
|
|
498
|
+
}
|
|
499
|
+
]
|
|
500
|
+
|
|
501
|
+
for (const invalidCase of invalidConnectCases) {
|
|
502
|
+
test(`checkout rejects ${invalidCase.name} before calling Bachs`, async () => {
|
|
503
|
+
let fetchCalls = 0
|
|
504
|
+
|
|
505
|
+
fetch.setFetchImplementation(async () => {
|
|
506
|
+
fetchCalls += 1
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
await assert.rejects(
|
|
510
|
+
() => checkout({ apiKey: 'sk_sandbox_123', ...invalidCase.inputs }),
|
|
511
|
+
(error) => {
|
|
512
|
+
assert.equal(error.exit, 'invalidRequest')
|
|
513
|
+
assert.equal(error.raw.field, invalidCase.field)
|
|
514
|
+
assert.match(error.raw.message, invalidCase.message)
|
|
515
|
+
return true
|
|
516
|
+
}
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
assert.equal(fetchCalls, 0)
|
|
520
|
+
})
|
|
521
|
+
}
|
|
@@ -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
|
+
})
|