@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.
@@ -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: buildProductCart(inputs.items),
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
@@ -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 shouldUseCheckoutSession = hasItems || hasProductCollection
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 (shouldUseCheckoutSession && hasItems === hasProductCollection) {
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 (!shouldUseCheckoutSession && !payload.pricing) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sails-pay/bachs",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Bachs adapter for Sails Pay",
5
5
  "main": "adapter.js",
6
6
  "scripts": {
@@ -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
+ }