@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,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
+ })
@@ -3,9 +3,147 @@ const assert = require('node:assert/strict')
3
3
  const {
4
4
  buildCheckoutSessionPayload,
5
5
  buildPureCheckoutPayload,
6
- buildRefundPayload
6
+ buildRefundPayload,
7
+ buildProductCart,
8
+ normalizePricing
7
9
  } = require('../helpers/payloads')
8
10
 
11
+ test('buildProductCart preserves catalog pricing and optional quantity', () => {
12
+ assert.deepEqual(buildProductCart([{ product: 'prod_catalog' }]), [
13
+ {
14
+ product_id: 'prod_catalog'
15
+ }
16
+ ])
17
+
18
+ assert.deepEqual(
19
+ buildProductCart([{ product: 'prod_quantity', quantity: 2 }]),
20
+ [
21
+ {
22
+ product_id: 'prod_quantity',
23
+ quantity: 2
24
+ }
25
+ ]
26
+ )
27
+ })
28
+
29
+ test('buildProductCart normalizes fixed pricing shorthand and explicit pricing', () => {
30
+ const fixedPricing = {
31
+ product_id: 'prod_fixed',
32
+ pricing: {
33
+ price_type: 'fixed',
34
+ amount: '19.00'
35
+ }
36
+ }
37
+
38
+ assert.deepEqual(
39
+ buildProductCart([{ product: 'prod_fixed', amount: '19.00' }]),
40
+ [fixedPricing]
41
+ )
42
+
43
+ assert.deepEqual(
44
+ buildProductCart([
45
+ {
46
+ product: 'prod_fixed',
47
+ pricing: {
48
+ type: 'fixed',
49
+ amount: '19.00'
50
+ }
51
+ }
52
+ ]),
53
+ [fixedPricing]
54
+ )
55
+ })
56
+
57
+ test('buildProductCart normalizes custom pricing and chosenAmount', () => {
58
+ assert.deepEqual(
59
+ buildProductCart([
60
+ {
61
+ product: 'prod_custom',
62
+ pricing: {
63
+ type: 'custom',
64
+ presetAmount: '10.00',
65
+ minimumAmount: '5.00',
66
+ maximumAmount: '100.00'
67
+ },
68
+ chosenAmount: '12.00'
69
+ }
70
+ ]),
71
+ [
72
+ {
73
+ product_id: 'prod_custom',
74
+ pricing: {
75
+ price_type: 'custom',
76
+ preset_amount: '10.00',
77
+ minimum_amount: '5.00',
78
+ maximum_amount: '100.00'
79
+ },
80
+ amount: '12.00'
81
+ }
82
+ ]
83
+ )
84
+ })
85
+
86
+ test('buildProductCart normalizes free pricing', () => {
87
+ assert.deepEqual(
88
+ buildProductCart([
89
+ {
90
+ product: 'prod_free',
91
+ pricing: {
92
+ type: 'free'
93
+ }
94
+ }
95
+ ]),
96
+ [
97
+ {
98
+ product_id: 'prod_free',
99
+ pricing: {
100
+ price_type: 'free'
101
+ }
102
+ }
103
+ ]
104
+ )
105
+ })
106
+
107
+ test('buildProductCart maps catalog chosenAmount to the flat Bachs amount', () => {
108
+ assert.deepEqual(
109
+ buildProductCart([
110
+ {
111
+ product: 'prod_catalog_custom',
112
+ chosenAmount: '12.00'
113
+ }
114
+ ]),
115
+ [
116
+ {
117
+ product_id: 'prod_catalog_custom',
118
+ amount: '12.00'
119
+ }
120
+ ]
121
+ )
122
+ })
123
+
124
+ test('normalizePricing maps reusable pricing fields to Bachs snake case', () => {
125
+ assert.deepEqual(
126
+ normalizePricing({
127
+ type: 'custom',
128
+ presetAmount: '10.00',
129
+ minimumAmount: '5.00',
130
+ maximumAmount: '100.00',
131
+ currencyOptions: {
132
+ NGN: '15000.00'
133
+ }
134
+ }),
135
+ {
136
+ price_type: 'custom',
137
+ preset_amount: '10.00',
138
+ minimum_amount: '5.00',
139
+ maximum_amount: '100.00',
140
+ currency_options: {
141
+ NGN: '15000.00'
142
+ }
143
+ }
144
+ )
145
+ })
146
+
9
147
  test('buildCheckoutSessionPayload maps product checkout inputs to Bachs snake case', () => {
10
148
  const payload = buildCheckoutSessionPayload(
11
149
  {
@@ -37,7 +175,10 @@ test('buildCheckoutSessionPayload maps product checkout inputs to Bachs snake ca
37
175
  {
38
176
  product_id: 'prod_abc123',
39
177
  quantity: 2,
40
- amount: '50.00'
178
+ pricing: {
179
+ price_type: 'fixed',
180
+ amount: '50.00'
181
+ }
41
182
  }
42
183
  ],
43
184
  billing_currency: 'NGN',
@@ -118,6 +259,34 @@ test('buildPureCheckoutPayload maps amount checkout inputs to Bachs snake case',
118
259
  })
119
260
  })
120
261
 
262
+ test('buildPureCheckoutPayload reuses advanced pricing normalization', () => {
263
+ const payload = buildPureCheckoutPayload({
264
+ pricing: {
265
+ type: 'custom',
266
+ currency: 'USD',
267
+ presetAmount: '10.00',
268
+ minimumAmount: '5.00',
269
+ maximumAmount: '100.00'
270
+ },
271
+ currencyOptions: {
272
+ NGN: '15000.00'
273
+ }
274
+ })
275
+
276
+ assert.deepEqual(payload, {
277
+ pricing: {
278
+ currency: 'USD',
279
+ price_type: 'custom',
280
+ preset_amount: '10.00',
281
+ minimum_amount: '5.00',
282
+ maximum_amount: '100.00',
283
+ currency_options: {
284
+ NGN: '15000.00'
285
+ }
286
+ }
287
+ })
288
+ })
289
+
121
290
  test('buildRefundPayload maps refund inputs to Bachs snake case', () => {
122
291
  const payload = buildRefundPayload({
123
292
  chargeId: 'chr_123',