@sails-pay/bachs 0.0.2 → 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.
@@ -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.2",
3
+ "version": "0.0.4",
4
4
  "description": "Bachs adapter for Sails Pay",
5
5
  "main": "adapter.js",
6
6
  "scripts": {
@@ -4,6 +4,11 @@ const adapter = require('../adapter')
4
4
  const checkout = require('../machines/checkout')
5
5
  const fetch = require('../helpers/fetch')
6
6
 
7
+ test.afterEach(() => {
8
+ fetch.resetFetchImplementation()
9
+ adapter.config = {}
10
+ })
11
+
7
12
  test('checkout creates a Bachs checkout session from camelCase inputs', async () => {
8
13
  const calls = []
9
14
 
@@ -24,7 +29,7 @@ test('checkout creates a Bachs checkout session from camelCase inputs', async ()
24
29
 
25
30
  const checkoutUrl = await checkout({
26
31
  apiKey: 'sk_sandbox_123',
27
- items: [{ product: 'prod_abc123', quantity: 1 }],
32
+ items: [{ product: 'prod_abc123' }],
28
33
  customer: {
29
34
  email: 'customer@example.com',
30
35
  name: 'Jane Doe'
@@ -47,18 +52,280 @@ test('checkout creates a Bachs checkout session from camelCase inputs', async ()
47
52
  },
48
53
  product_cart: [
49
54
  {
50
- product_id: 'prod_abc123',
51
- quantity: 1
55
+ product_id: 'prod_abc123'
52
56
  }
53
57
  ],
54
58
  return_url: 'https://example.com/return',
55
59
  cancel_url: 'https://example.com/cancel',
56
60
  reference: 'order_123'
57
61
  })
62
+ })
58
63
 
59
- fetch.resetFetchImplementation()
64
+ test('checkout sends custom pricing and the buyer-selected amount', async () => {
65
+ const calls = []
66
+
67
+ fetch.setFetchImplementation(async (url, options) => {
68
+ calls.push({ url, options })
69
+
70
+ return {
71
+ ok: true,
72
+ status: 201,
73
+ statusText: 'Created',
74
+ text: async () =>
75
+ JSON.stringify({
76
+ checkout_url: 'https://pay.bachs.io/c/custom'
77
+ })
78
+ }
79
+ })
80
+
81
+ const checkoutUrl = await checkout({
82
+ apiKey: 'sk_sandbox_123',
83
+ items: [
84
+ {
85
+ product: 'prod_custom',
86
+ pricing: {
87
+ type: 'custom',
88
+ presetAmount: '10.00',
89
+ minimumAmount: '5.00',
90
+ maximumAmount: '100.00'
91
+ },
92
+ chosenAmount: '12.00'
93
+ }
94
+ ]
95
+ })
96
+
97
+ assert.equal(checkoutUrl, 'https://pay.bachs.io/c/custom')
98
+ assert.deepEqual(JSON.parse(calls[0].options.body), {
99
+ customer: {},
100
+ product_cart: [
101
+ {
102
+ product_id: 'prod_custom',
103
+ pricing: {
104
+ price_type: 'custom',
105
+ preset_amount: '10.00',
106
+ minimum_amount: '5.00',
107
+ maximum_amount: '100.00'
108
+ },
109
+ amount: '12.00'
110
+ }
111
+ ]
112
+ })
113
+ })
114
+
115
+ test('checkout preserves pure checkout behavior', async () => {
116
+ const calls = []
117
+
118
+ fetch.setFetchImplementation(async (url, options) => {
119
+ calls.push({ url, options })
120
+
121
+ return {
122
+ ok: true,
123
+ status: 201,
124
+ statusText: 'Created',
125
+ text: async () =>
126
+ JSON.stringify({
127
+ checkout_url: 'https://pay.bachs.io/c/pure'
128
+ })
129
+ }
130
+ })
131
+
132
+ const checkoutUrl = await checkout({
133
+ apiKey: 'sk_sandbox_123',
134
+ amount: '42.00',
135
+ currency: 'USD',
136
+ reference: 'pure_123'
137
+ })
138
+
139
+ assert.equal(checkoutUrl, 'https://pay.bachs.io/c/pure')
140
+ assert.equal(calls[0].url, 'https://sandbox-api.bachs.io/v1/checkouts')
141
+ assert.equal(calls[0].options.headers['Idempotency-Key'], 'pure_123')
142
+ assert.deepEqual(JSON.parse(calls[0].options.body), {
143
+ pricing: {
144
+ currency: 'USD',
145
+ amount: '42.00'
146
+ },
147
+ reference: 'pure_123'
148
+ })
60
149
  })
61
150
 
151
+ const invalidItemCases = [
152
+ {
153
+ name: 'amount with explicit pricing',
154
+ item: {
155
+ product: 'prod_123',
156
+ amount: '19.00',
157
+ pricing: { type: 'fixed', amount: '19.00' }
158
+ },
159
+ field: 'items[0]',
160
+ message: /both amount and pricing/
161
+ },
162
+ {
163
+ name: 'an unsupported pricing type',
164
+ item: {
165
+ product: 'prod_123',
166
+ pricing: { type: 'metered' }
167
+ },
168
+ field: 'items[0].pricing.type',
169
+ message: /fixed, custom, or free/
170
+ },
171
+ {
172
+ name: 'fixed pricing without an amount',
173
+ item: {
174
+ product: 'prod_123',
175
+ pricing: { type: 'fixed' }
176
+ },
177
+ field: 'items[0].pricing.amount',
178
+ message: /required for fixed pricing/
179
+ },
180
+ ...['presetAmount', 'minimumAmount', 'maximumAmount'].map((field) => ({
181
+ name: `fixed pricing with ${field}`,
182
+ item: {
183
+ product: 'prod_123',
184
+ pricing: {
185
+ type: 'fixed',
186
+ amount: '19.00',
187
+ [field]: '10.00'
188
+ }
189
+ },
190
+ field: `items[0].pricing.${field}`,
191
+ message: /not allowed with fixed pricing/
192
+ })),
193
+ {
194
+ name: 'custom pricing with a fixed amount',
195
+ item: {
196
+ product: 'prod_123',
197
+ pricing: {
198
+ type: 'custom',
199
+ amount: '19.00'
200
+ }
201
+ },
202
+ field: 'items[0].pricing.amount',
203
+ message: /not allowed with custom pricing/
204
+ },
205
+ ...['amount', 'presetAmount', 'minimumAmount', 'maximumAmount'].map(
206
+ (field) => ({
207
+ name: `free pricing with ${field}`,
208
+ item: {
209
+ product: 'prod_123',
210
+ pricing: {
211
+ type: 'free',
212
+ [field]: '10.00'
213
+ }
214
+ },
215
+ field: `items[0].pricing.${field}`,
216
+ message: /not allowed with free pricing/
217
+ })
218
+ ),
219
+ {
220
+ name: 'free pricing with chosenAmount',
221
+ item: {
222
+ product: 'prod_123',
223
+ pricing: { type: 'free' },
224
+ chosenAmount: '10.00'
225
+ },
226
+ field: 'items[0].chosenAmount',
227
+ message: /not allowed with free pricing/
228
+ },
229
+ {
230
+ name: 'fixed amount shorthand with chosenAmount',
231
+ item: {
232
+ product: 'prod_123',
233
+ amount: '19.00',
234
+ chosenAmount: '10.00'
235
+ },
236
+ field: 'items[0].chosenAmount',
237
+ message: /catalog custom pricing or ad-hoc custom pricing/
238
+ },
239
+ {
240
+ name: 'explicit fixed pricing with chosenAmount',
241
+ item: {
242
+ product: 'prod_123',
243
+ pricing: { type: 'fixed', amount: '19.00' },
244
+ chosenAmount: '10.00'
245
+ },
246
+ field: 'items[0].chosenAmount',
247
+ message: /catalog custom pricing or ad-hoc custom pricing/
248
+ },
249
+ {
250
+ name: 'a numeric amount shorthand',
251
+ item: {
252
+ product: 'prod_123',
253
+ amount: 1900
254
+ },
255
+ field: 'items[0].amount',
256
+ message: /decimal string/
257
+ },
258
+ {
259
+ name: 'a numeric chosenAmount',
260
+ item: {
261
+ product: 'prod_123',
262
+ chosenAmount: 1200
263
+ },
264
+ field: 'items[0].chosenAmount',
265
+ message: /decimal string/
266
+ },
267
+ {
268
+ name: 'a numeric fixed pricing amount',
269
+ item: {
270
+ product: 'prod_123',
271
+ pricing: { type: 'fixed', amount: 1900 }
272
+ },
273
+ field: 'items[0].pricing.amount',
274
+ message: /decimal string/
275
+ },
276
+ ...['presetAmount', 'minimumAmount', 'maximumAmount'].map((field) => ({
277
+ name: `a numeric custom pricing ${field}`,
278
+ item: {
279
+ product: 'prod_123',
280
+ pricing: {
281
+ type: 'custom',
282
+ [field]: 1000
283
+ }
284
+ },
285
+ field: `items[0].pricing.${field}`,
286
+ message: /decimal string/
287
+ })),
288
+ ...[
289
+ ['zero', 0],
290
+ ['a fraction', 1.5],
291
+ ['a string', '2']
292
+ ].map(([description, quantity]) => ({
293
+ name: `${description} quantity`,
294
+ item: {
295
+ product: 'prod_123',
296
+ quantity
297
+ },
298
+ field: 'items[0].quantity',
299
+ message: /integer of at least 1/
300
+ }))
301
+ ]
302
+
303
+ for (const invalidCase of invalidItemCases) {
304
+ test(`checkout rejects ${invalidCase.name} before calling Bachs`, async () => {
305
+ let fetchCalls = 0
306
+
307
+ fetch.setFetchImplementation(async () => {
308
+ fetchCalls += 1
309
+ })
310
+
311
+ await assert.rejects(
312
+ () =>
313
+ checkout({
314
+ apiKey: 'sk_sandbox_123',
315
+ items: [invalidCase.item]
316
+ }),
317
+ (error) => {
318
+ assert.equal(error.exit, 'invalidRequest')
319
+ assert.equal(error.raw.field, invalidCase.field)
320
+ assert.match(error.raw.message, invalidCase.message)
321
+ return true
322
+ }
323
+ )
324
+
325
+ assert.equal(fetchCalls, 0)
326
+ })
327
+ }
328
+
62
329
  test('adapter exposes checkout.get as the uniform checkout lookup API', async () => {
63
330
  const calls = []
64
331
 
@@ -95,6 +362,4 @@ test('adapter exposes checkout.get as the uniform checkout lookup API', async ()
95
362
  'https://sandbox-api.bachs.io/v1/checkouts/chk_123'
96
363
  )
97
364
  assert.equal(calls[0].options.method, 'GET')
98
-
99
- fetch.resetFetchImplementation()
100
365
  })