@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.
package/adapter.js CHANGED
@@ -4,6 +4,7 @@ module.exports = {
4
4
  identity: 'sails-bachs',
5
5
  config: {},
6
6
  checkout: methods.checkout,
7
+ connect: methods.connect,
7
8
  customer: {
8
9
  portal: methods.customer.portal
9
10
  },
@@ -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
+ }
@@ -44,13 +44,22 @@ function buildProductCart(items) {
44
44
  return undefined
45
45
  }
46
46
 
47
- return items.map((item) =>
48
- withoutUndefined({
47
+ return items.map((item) => {
48
+ const pricing =
49
+ item.amount !== undefined
50
+ ? normalizePricing({
51
+ type: 'fixed',
52
+ amount: item.amount
53
+ })
54
+ : normalizePricing(item.pricing)
55
+
56
+ return withoutUndefined({
49
57
  product_id: item.product || item.productId,
50
58
  quantity: item.quantity,
51
- amount: item.amount
59
+ pricing,
60
+ amount: item.chosenAmount
52
61
  })
53
- )
62
+ })
54
63
  }
55
64
 
56
65
  function buildCheckoutSessionPayload(inputs, adapterConfig = {}) {
@@ -76,25 +85,44 @@ function buildCheckoutSessionPayload(inputs, adapterConfig = {}) {
76
85
  })
77
86
  }
78
87
 
79
- function buildPricingPayload(inputs) {
80
- let pricing
88
+ function normalizePricing(pricing, fallbackCurrencyOptions) {
89
+ if (!pricing || typeof pricing !== 'object' || Array.isArray(pricing)) {
90
+ return undefined
91
+ }
81
92
 
82
- if (inputs.pricing) {
83
- const { currencyOptions, ...pricingInput } = inputs.pricing
93
+ const {
94
+ type,
95
+ presetAmount,
96
+ minimumAmount,
97
+ maximumAmount,
98
+ currencyOptions,
99
+ ...pricingInput
100
+ } = pricing
101
+ const normalizedPricing = withoutUndefined({
102
+ ...pricingInput,
103
+ price_type: type,
104
+ preset_amount: presetAmount,
105
+ minimum_amount: minimumAmount,
106
+ maximum_amount: maximumAmount,
107
+ currency_options:
108
+ currencyOptions === undefined ? fallbackCurrencyOptions : currencyOptions
109
+ })
84
110
 
85
- pricing = withoutUndefined({
86
- ...pricingInput,
87
- currency_options: currencyOptions || inputs.currencyOptions
88
- })
89
- } else {
90
- pricing = withoutUndefined({
91
- currency: inputs.currency,
92
- amount: inputs.amount,
93
- currency_options: inputs.currencyOptions
94
- })
111
+ return Object.keys(normalizedPricing).length > 0
112
+ ? normalizedPricing
113
+ : undefined
114
+ }
115
+
116
+ function buildPricingPayload(inputs) {
117
+ if (inputs.pricing) {
118
+ return normalizePricing(inputs.pricing, inputs.currencyOptions)
95
119
  }
96
120
 
97
- return Object.keys(pricing).length > 0 ? pricing : undefined
121
+ return normalizePricing({
122
+ currency: inputs.currency,
123
+ amount: inputs.amount,
124
+ currencyOptions: inputs.currencyOptions
125
+ })
98
126
  }
99
127
 
100
128
  function buildPureCheckoutPayload(inputs, adapterConfig = {}) {
@@ -142,5 +170,6 @@ module.exports = {
142
170
  buildRefundPayload,
143
171
  buildProductCart,
144
172
  buildCustomerPayload,
173
+ normalizePricing,
145
174
  withoutUndefined
146
175
  }
@@ -0,0 +1,177 @@
1
+ const pricingTypes = new Set(['fixed', 'custom', 'free'])
2
+ const pricingMoneyFields = [
3
+ 'amount',
4
+ 'presetAmount',
5
+ 'minimumAmount',
6
+ 'maximumAmount'
7
+ ]
8
+ const customPricingFields = ['presetAmount', 'minimumAmount', 'maximumAmount']
9
+ const decimalStringPattern = /^\d+(?:\.\d+)?$/
10
+
11
+ function fieldWasProvided(object, field) {
12
+ return object[field] !== undefined
13
+ }
14
+
15
+ function invalid(field, message) {
16
+ return {
17
+ field,
18
+ message: `${field} ${message}`
19
+ }
20
+ }
21
+
22
+ function validateMoney(value, field) {
23
+ if (typeof value !== 'string' || !decimalStringPattern.test(value)) {
24
+ return invalid(field, 'must be a non-negative decimal string.')
25
+ }
26
+ }
27
+
28
+ function validatePricingMoney(pricing, itemPath) {
29
+ for (const field of pricingMoneyFields) {
30
+ if (fieldWasProvided(pricing, field)) {
31
+ const error = validateMoney(
32
+ pricing[field],
33
+ `${itemPath}.pricing.${field}`
34
+ )
35
+
36
+ if (error) {
37
+ return error
38
+ }
39
+ }
40
+ }
41
+ }
42
+
43
+ function validateItem(item, index) {
44
+ const itemPath = `items[${index}]`
45
+
46
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
47
+ return invalid(itemPath, 'must be an object.')
48
+ }
49
+
50
+ if (
51
+ fieldWasProvided(item, 'quantity') &&
52
+ (!Number.isInteger(item.quantity) || item.quantity < 1)
53
+ ) {
54
+ return invalid(`${itemPath}.quantity`, 'must be an integer of at least 1.')
55
+ }
56
+
57
+ const hasAmount = fieldWasProvided(item, 'amount')
58
+ const hasPricing = fieldWasProvided(item, 'pricing')
59
+ const hasChosenAmount = fieldWasProvided(item, 'chosenAmount')
60
+
61
+ if (hasAmount && hasPricing) {
62
+ return invalid(
63
+ itemPath,
64
+ 'must not provide both amount and pricing; amount is the fixed-price shorthand.'
65
+ )
66
+ }
67
+
68
+ if (hasAmount) {
69
+ const error = validateMoney(item.amount, `${itemPath}.amount`)
70
+
71
+ if (error) {
72
+ return error
73
+ }
74
+
75
+ if (hasChosenAmount) {
76
+ return invalid(
77
+ `${itemPath}.chosenAmount`,
78
+ 'can only be used with catalog custom pricing or ad-hoc custom pricing.'
79
+ )
80
+ }
81
+ }
82
+
83
+ if (hasChosenAmount) {
84
+ const error = validateMoney(item.chosenAmount, `${itemPath}.chosenAmount`)
85
+
86
+ if (error) {
87
+ return error
88
+ }
89
+ }
90
+
91
+ if (!hasPricing) {
92
+ return
93
+ }
94
+
95
+ if (
96
+ !item.pricing ||
97
+ typeof item.pricing !== 'object' ||
98
+ Array.isArray(item.pricing)
99
+ ) {
100
+ return invalid(`${itemPath}.pricing`, 'must be an object.')
101
+ }
102
+
103
+ const pricing = item.pricing
104
+
105
+ if (!pricingTypes.has(pricing.type)) {
106
+ return invalid(
107
+ `${itemPath}.pricing.type`,
108
+ 'must be fixed, custom, or free.'
109
+ )
110
+ }
111
+
112
+ if (pricing.type === 'fixed') {
113
+ if (!fieldWasProvided(pricing, 'amount')) {
114
+ return invalid(
115
+ `${itemPath}.pricing.amount`,
116
+ 'is required for fixed pricing.'
117
+ )
118
+ }
119
+
120
+ const customField = customPricingFields.find((field) =>
121
+ fieldWasProvided(pricing, field)
122
+ )
123
+
124
+ if (customField) {
125
+ return invalid(
126
+ `${itemPath}.pricing.${customField}`,
127
+ 'is not allowed with fixed pricing.'
128
+ )
129
+ }
130
+
131
+ if (hasChosenAmount) {
132
+ return invalid(
133
+ `${itemPath}.chosenAmount`,
134
+ 'can only be used with catalog custom pricing or ad-hoc custom pricing.'
135
+ )
136
+ }
137
+ }
138
+
139
+ if (pricing.type === 'custom' && fieldWasProvided(pricing, 'amount')) {
140
+ return invalid(
141
+ `${itemPath}.pricing.amount`,
142
+ 'is not allowed with custom pricing.'
143
+ )
144
+ }
145
+
146
+ if (pricing.type === 'free') {
147
+ const monetaryField = pricingMoneyFields.find((field) =>
148
+ fieldWasProvided(pricing, field)
149
+ )
150
+
151
+ if (monetaryField) {
152
+ return invalid(
153
+ `${itemPath}.pricing.${monetaryField}`,
154
+ 'is not allowed with free pricing.'
155
+ )
156
+ }
157
+
158
+ if (hasChosenAmount) {
159
+ return invalid(
160
+ `${itemPath}.chosenAmount`,
161
+ 'is not allowed with free pricing.'
162
+ )
163
+ }
164
+ }
165
+
166
+ return validatePricingMoney(pricing, itemPath)
167
+ }
168
+
169
+ module.exports = function validateCheckoutItems(items) {
170
+ for (let index = 0; index < items.length; index++) {
171
+ const error = validateItem(items[index], index)
172
+
173
+ if (error) {
174
+ return error
175
+ }
176
+ }
177
+ }
@@ -3,6 +3,7 @@ const {
3
3
  buildCheckoutSessionPayload,
4
4
  buildPureCheckoutPayload
5
5
  } = require('../helpers/payloads')
6
+ const validateCheckoutItems = require('../helpers/validate-checkout-items')
6
7
  const parameters = require('../helpers/parameters')
7
8
 
8
9
  module.exports = require('machine').build({
@@ -16,7 +17,7 @@ module.exports = require('machine').build({
16
17
  items: {
17
18
  type: 'ref',
18
19
  description:
19
- 'Product items for Bachs Checkout Sessions. Each item should use product or productId plus optional quantity and amount.'
20
+ 'Product items for Bachs Checkout Sessions. Supports catalog pricing, fixed amount shorthand, advanced pricing, and chosenAmount.'
20
21
  },
21
22
  productCollectionId: {
22
23
  type: 'string',
@@ -141,10 +142,6 @@ module.exports = require('machine').build({
141
142
  inputs.productCollectionId || inputs.productCollection
142
143
  )
143
144
  const shouldUseCheckoutSession = hasItems || hasProductCollection
144
- const path = shouldUseCheckoutSession ? '/checkout-sessions' : '/checkouts'
145
- const payload = shouldUseCheckoutSession
146
- ? buildCheckoutSessionPayload(inputs, adapterConfig)
147
- : buildPureCheckoutPayload(inputs, adapterConfig)
148
145
 
149
146
  if (shouldUseCheckoutSession && hasItems === hasProductCollection) {
150
147
  return exits.invalidRequest({
@@ -153,6 +150,19 @@ module.exports = require('machine').build({
153
150
  })
154
151
  }
155
152
 
153
+ if (hasItems) {
154
+ const validationError = validateCheckoutItems(inputs.items)
155
+
156
+ if (validationError) {
157
+ return exits.invalidRequest(validationError)
158
+ }
159
+ }
160
+
161
+ const path = shouldUseCheckoutSession ? '/checkout-sessions' : '/checkouts'
162
+ const payload = shouldUseCheckoutSession
163
+ ? buildCheckoutSessionPayload(inputs, adapterConfig)
164
+ : buildPureCheckoutPayload(inputs, adapterConfig)
165
+
156
166
  if (!shouldUseCheckoutSession && !payload.pricing) {
157
167
  return exits.invalidRequest({
158
168
  message:
@@ -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
+ })