@sails-pay/bachs 0.0.4 → 0.0.6
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/helpers/payloads.js +12 -2
- package/helpers/validate-checkout-connect.js +54 -0
- package/machines/checkout.js +32 -3
- package/package.json +1 -1
- package/test/checkout.test.js +210 -0
package/helpers/payloads.js
CHANGED
|
@@ -65,6 +65,7 @@ function buildProductCart(items) {
|
|
|
65
65
|
function buildCheckoutSessionPayload(inputs, adapterConfig = {}) {
|
|
66
66
|
const productCollectionId =
|
|
67
67
|
inputs.productCollectionId || inputs.productCollection
|
|
68
|
+
const productCart = buildProductCart(inputs.items)
|
|
68
69
|
const checkoutData = inputs.checkoutData || {}
|
|
69
70
|
const returnUrl =
|
|
70
71
|
inputs.returnUrl ||
|
|
@@ -74,14 +75,23 @@ function buildCheckoutSessionPayload(inputs, adapterConfig = {}) {
|
|
|
74
75
|
|
|
75
76
|
return withoutUndefined({
|
|
76
77
|
customer: buildCustomerPayload(inputs),
|
|
77
|
-
product_cart:
|
|
78
|
+
product_cart: productCart,
|
|
78
79
|
product_collection_id: productCollectionId,
|
|
80
|
+
// A session prices itself with products or with a raw amount, never both.
|
|
81
|
+
pricing:
|
|
82
|
+
productCart || productCollectionId
|
|
83
|
+
? undefined
|
|
84
|
+
: buildPricingPayload(inputs),
|
|
79
85
|
billing_currency: inputs.billingCurrency,
|
|
80
86
|
allowed_payment_method_types: inputs.allowedPaymentMethodTypes,
|
|
81
87
|
return_url: returnUrl,
|
|
82
88
|
cancel_url: inputs.cancelUrl || adapterConfig.cancelUrl,
|
|
83
89
|
reference: inputs.reference,
|
|
84
|
-
metadata: inputs.metadata || checkoutData.custom
|
|
90
|
+
metadata: inputs.metadata || checkoutData.custom,
|
|
91
|
+
transfer_data: inputs.connect && {
|
|
92
|
+
destination: inputs.connect.destination
|
|
93
|
+
},
|
|
94
|
+
platform_fee: inputs.connect && inputs.connect.platformFee
|
|
85
95
|
})
|
|
86
96
|
}
|
|
87
97
|
|
|
@@ -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 checkout sessions (items, productCollectionId, or pricing).'
|
|
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: {
|
|
@@ -141,9 +147,18 @@ module.exports = require('machine').build({
|
|
|
141
147
|
const hasProductCollection = Boolean(
|
|
142
148
|
inputs.productCollectionId || inputs.productCollection
|
|
143
149
|
)
|
|
144
|
-
const
|
|
150
|
+
const hasPricing = Boolean(
|
|
151
|
+
inputs.pricing || inputs.amount !== undefined || inputs.currency
|
|
152
|
+
)
|
|
153
|
+
// A split belongs to the platform, and Bachs only splits checkout
|
|
154
|
+
// sessions. A session takes products or a raw price, so a checkout that
|
|
155
|
+
// states its own amount and currency can be split too.
|
|
156
|
+
const shouldUseCheckoutSession =
|
|
157
|
+
hasItems ||
|
|
158
|
+
hasProductCollection ||
|
|
159
|
+
(hasPricing && inputs.connect !== undefined)
|
|
145
160
|
|
|
146
|
-
if (
|
|
161
|
+
if (hasItems && hasProductCollection) {
|
|
147
162
|
return exits.invalidRequest({
|
|
148
163
|
message:
|
|
149
164
|
'Provide exactly one of items or productCollectionId for a Bachs checkout session.'
|
|
@@ -158,12 +173,26 @@ module.exports = require('machine').build({
|
|
|
158
173
|
}
|
|
159
174
|
}
|
|
160
175
|
|
|
176
|
+
if (inputs.connect !== undefined) {
|
|
177
|
+
const connectError = validateCheckoutConnect(inputs.connect, {
|
|
178
|
+
isCheckoutSession: shouldUseCheckoutSession
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
if (connectError) {
|
|
182
|
+
return exits.invalidRequest(connectError)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
161
186
|
const path = shouldUseCheckoutSession ? '/checkout-sessions' : '/checkouts'
|
|
162
187
|
const payload = shouldUseCheckoutSession
|
|
163
188
|
? buildCheckoutSessionPayload(inputs, adapterConfig)
|
|
164
189
|
: buildPureCheckoutPayload(inputs, adapterConfig)
|
|
165
190
|
|
|
166
|
-
if (
|
|
191
|
+
if (
|
|
192
|
+
!payload.product_cart &&
|
|
193
|
+
!payload.product_collection_id &&
|
|
194
|
+
!payload.pricing
|
|
195
|
+
) {
|
|
167
196
|
return exits.invalidRequest({
|
|
168
197
|
message:
|
|
169
198
|
'Provide product items/productCollectionId or pure checkout pricing.'
|
package/package.json
CHANGED
package/test/checkout.test.js
CHANGED
|
@@ -363,3 +363,213 @@ 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 splits a product-less checkout session', 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/raw-split' })
|
|
420
|
+
}
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
const checkoutUrl = await checkout({
|
|
424
|
+
apiKey: 'sk_sandbox_123',
|
|
425
|
+
amount: '10500.00',
|
|
426
|
+
currency: 'NGN',
|
|
427
|
+
billingCurrency: 'NGN',
|
|
428
|
+
customer: { email: 'sponsor@example.com' },
|
|
429
|
+
reference: 'sponsorship_2',
|
|
430
|
+
connect: { destination: 'acct_123', platformFee: '500.00' }
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
assert.equal(checkoutUrl, 'https://pay.bachs.io/c/raw-split')
|
|
434
|
+
assert.equal(
|
|
435
|
+
calls[0].url,
|
|
436
|
+
'https://sandbox-api.bachs.io/v1/checkout-sessions'
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
const body = JSON.parse(calls[0].options.body)
|
|
440
|
+
assert.deepEqual(body.pricing, { currency: 'NGN', amount: '10500.00' })
|
|
441
|
+
assert.deepEqual(body.transfer_data, { destination: 'acct_123' })
|
|
442
|
+
assert.equal(body.platform_fee, '500.00')
|
|
443
|
+
assert.equal(body.billing_currency, 'NGN')
|
|
444
|
+
assert.equal('product_cart' in body, false)
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test('a pure checkout without connect still posts to /checkouts', async () => {
|
|
448
|
+
const calls = []
|
|
449
|
+
|
|
450
|
+
fetch.setFetchImplementation(async (url, options) => {
|
|
451
|
+
calls.push({ url, options })
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
ok: true,
|
|
455
|
+
status: 201,
|
|
456
|
+
statusText: 'Created',
|
|
457
|
+
text: async () =>
|
|
458
|
+
JSON.stringify({ checkout_url: 'https://pay.bachs.io/c/raw' })
|
|
459
|
+
}
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
await checkout({
|
|
463
|
+
apiKey: 'sk_sandbox_123',
|
|
464
|
+
amount: '10500.00',
|
|
465
|
+
currency: 'NGN',
|
|
466
|
+
customer: { email: 'sponsor@example.com' }
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
assert.equal(calls[0].url, 'https://sandbox-api.bachs.io/v1/checkouts')
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
test('checkout without connect sends no split fields', async () => {
|
|
473
|
+
const calls = []
|
|
474
|
+
|
|
475
|
+
fetch.setFetchImplementation(async (url, options) => {
|
|
476
|
+
calls.push({ url, options })
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
ok: true,
|
|
480
|
+
status: 201,
|
|
481
|
+
statusText: 'Created',
|
|
482
|
+
text: async () =>
|
|
483
|
+
JSON.stringify({ checkout_url: 'https://pay.bachs.io/c/plain' })
|
|
484
|
+
}
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
await checkout({
|
|
488
|
+
apiKey: 'sk_sandbox_123',
|
|
489
|
+
items: [{ product: 'prod_abc123' }]
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
const body = JSON.parse(calls[0].options.body)
|
|
493
|
+
assert.equal('transfer_data' in body, false)
|
|
494
|
+
assert.equal('platform_fee' in body, false)
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
const invalidConnectCases = [
|
|
498
|
+
{
|
|
499
|
+
name: 'connect that is not an object',
|
|
500
|
+
inputs: { items: [{ product: 'prod_abc123' }], connect: 'acct_123' },
|
|
501
|
+
field: 'connect',
|
|
502
|
+
message: /must be an object/
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
name: 'connect without a destination',
|
|
506
|
+
inputs: {
|
|
507
|
+
items: [{ product: 'prod_abc123' }],
|
|
508
|
+
connect: { platformFee: '500.00' }
|
|
509
|
+
},
|
|
510
|
+
field: 'connect.destination',
|
|
511
|
+
message: /connected account ID/
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
name: 'connect without a platform fee',
|
|
515
|
+
inputs: {
|
|
516
|
+
items: [{ product: 'prod_abc123' }],
|
|
517
|
+
connect: { destination: 'acct_123' }
|
|
518
|
+
},
|
|
519
|
+
field: 'connect.platformFee',
|
|
520
|
+
message: /positive decimal string/
|
|
521
|
+
},
|
|
522
|
+
{
|
|
523
|
+
name: 'a numeric platform fee',
|
|
524
|
+
inputs: {
|
|
525
|
+
items: [{ product: 'prod_abc123' }],
|
|
526
|
+
connect: { destination: 'acct_123', platformFee: 500 }
|
|
527
|
+
},
|
|
528
|
+
field: 'connect.platformFee',
|
|
529
|
+
message: /positive decimal string/
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
name: 'a zero platform fee',
|
|
533
|
+
inputs: {
|
|
534
|
+
items: [{ product: 'prod_abc123' }],
|
|
535
|
+
connect: { destination: 'acct_123', platformFee: '0.00' }
|
|
536
|
+
},
|
|
537
|
+
field: 'connect.platformFee',
|
|
538
|
+
message: /positive decimal string/
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
name: 'an unknown connect field',
|
|
542
|
+
inputs: {
|
|
543
|
+
items: [{ product: 'prod_abc123' }],
|
|
544
|
+
connect: {
|
|
545
|
+
destination: 'acct_123',
|
|
546
|
+
platformFee: '500.00',
|
|
547
|
+
amount: '10000.00'
|
|
548
|
+
}
|
|
549
|
+
},
|
|
550
|
+
field: 'connect.amount',
|
|
551
|
+
message: /is not supported/
|
|
552
|
+
}
|
|
553
|
+
]
|
|
554
|
+
|
|
555
|
+
for (const invalidCase of invalidConnectCases) {
|
|
556
|
+
test(`checkout rejects ${invalidCase.name} before calling Bachs`, async () => {
|
|
557
|
+
let fetchCalls = 0
|
|
558
|
+
|
|
559
|
+
fetch.setFetchImplementation(async () => {
|
|
560
|
+
fetchCalls += 1
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
await assert.rejects(
|
|
564
|
+
() => checkout({ apiKey: 'sk_sandbox_123', ...invalidCase.inputs }),
|
|
565
|
+
(error) => {
|
|
566
|
+
assert.equal(error.exit, 'invalidRequest')
|
|
567
|
+
assert.equal(error.raw.field, invalidCase.field)
|
|
568
|
+
assert.match(error.raw.message, invalidCase.message)
|
|
569
|
+
return true
|
|
570
|
+
}
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
assert.equal(fetchCalls, 0)
|
|
574
|
+
})
|
|
575
|
+
}
|