@sails-pay/bachs 0.0.1
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/LICENSE +21 -0
- package/adapter.js +14 -0
- package/helpers/fetch.js +88 -0
- package/helpers/normalize-error.js +20 -0
- package/helpers/parameters.js +47 -0
- package/helpers/payloads.js +146 -0
- package/machines/checkout/get.js +47 -0
- package/machines/checkout.js +181 -0
- package/machines/index.js +14 -0
- package/machines/refund/create.js +79 -0
- package/machines/verify.js +47 -0
- package/machines/webhooks/verify.js +103 -0
- package/package.json +38 -0
- package/test/checkout.test.js +100 -0
- package/test/fetch.test.js +104 -0
- package/test/payloads.test.js +143 -0
- package/test/webhooks.test.js +82 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 The Sailscasts Company
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/adapter.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const methods = require('./machines')
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
identity: 'sails-bachs',
|
|
5
|
+
config: {},
|
|
6
|
+
checkout: methods.checkout,
|
|
7
|
+
verify: methods.verify,
|
|
8
|
+
webhooks: {
|
|
9
|
+
verify: methods.webhooks.verify
|
|
10
|
+
},
|
|
11
|
+
refund: {
|
|
12
|
+
create: methods.refund.create
|
|
13
|
+
}
|
|
14
|
+
}
|
package/helpers/fetch.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const { fetch: undiciFetch } = require('undici')
|
|
2
|
+
const normalizeError = require('./normalize-error')
|
|
3
|
+
|
|
4
|
+
const liveBaseUrl = 'https://api.bachs.io'
|
|
5
|
+
const sandboxBaseUrl = 'https://sandbox-api.bachs.io'
|
|
6
|
+
const defaultHeaders = {
|
|
7
|
+
Accept: 'application/json',
|
|
8
|
+
'Content-Type': 'application/json'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let fetchImpl = typeof global.fetch !== 'undefined' ? global.fetch : undiciFetch
|
|
12
|
+
|
|
13
|
+
function resolveBaseUrl({ apiKey, baseUrl } = {}) {
|
|
14
|
+
if (baseUrl) {
|
|
15
|
+
return baseUrl
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (apiKey && apiKey.startsWith('sk_sandbox_')) {
|
|
19
|
+
return sandboxBaseUrl
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return liveBaseUrl
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getVersionedPath(path) {
|
|
26
|
+
if (path.startsWith('/v1/')) {
|
|
27
|
+
return path
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return `/v1${path.startsWith('/') ? path : `/${path}`}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function parseJsonResponse(response) {
|
|
34
|
+
const text = await response.text()
|
|
35
|
+
|
|
36
|
+
if (!text) {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(text)
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return text
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const fetch = async (path, options = {}) => {
|
|
48
|
+
const { apiKey, baseUrl, idempotencyKey, headers, body, ...requestOptions } =
|
|
49
|
+
options
|
|
50
|
+
const url = new URL(
|
|
51
|
+
getVersionedPath(path),
|
|
52
|
+
resolveBaseUrl({ apiKey, baseUrl })
|
|
53
|
+
).toString()
|
|
54
|
+
|
|
55
|
+
const mergedHeaders = {
|
|
56
|
+
...defaultHeaders,
|
|
57
|
+
...(apiKey && { Authorization: `Bearer ${apiKey}` }),
|
|
58
|
+
...(idempotencyKey && { 'Idempotency-Key': idempotencyKey }),
|
|
59
|
+
...(headers || {})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const response = await fetchImpl(url, {
|
|
63
|
+
...requestOptions,
|
|
64
|
+
headers: mergedHeaders,
|
|
65
|
+
...(body !== undefined && {
|
|
66
|
+
body: typeof body === 'string' ? body : JSON.stringify(body)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
const responseBody = await parseJsonResponse(response)
|
|
70
|
+
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
const error = new Error('Bachs API request failed')
|
|
73
|
+
error.bachs = normalizeError(responseBody, response)
|
|
74
|
+
throw error
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return responseBody
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
fetch.resolveBaseUrl = resolveBaseUrl
|
|
81
|
+
fetch.setFetchImplementation = function setFetchImplementation(implementation) {
|
|
82
|
+
fetchImpl = implementation
|
|
83
|
+
}
|
|
84
|
+
fetch.resetFetchImplementation = function resetFetchImplementation() {
|
|
85
|
+
fetchImpl = typeof global.fetch !== 'undefined' ? global.fetch : undiciFetch
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = fetch
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
function normalizeError(error, response) {
|
|
2
|
+
const body = error || {}
|
|
3
|
+
const nestedError = body.error || {}
|
|
4
|
+
|
|
5
|
+
return {
|
|
6
|
+
statusCode: response && response.status,
|
|
7
|
+
statusText: response && response.statusText,
|
|
8
|
+
code: body.error_code || nestedError.code,
|
|
9
|
+
message:
|
|
10
|
+
body.detail ||
|
|
11
|
+
body.message ||
|
|
12
|
+
nestedError.message ||
|
|
13
|
+
(response && response.statusText) ||
|
|
14
|
+
'Bachs API request failed',
|
|
15
|
+
errors: body.errors || nestedError.details,
|
|
16
|
+
body
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = normalizeError
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Common input definitions shared by the Bachs machines.
|
|
3
|
+
*
|
|
4
|
+
* @type {Dictionary}
|
|
5
|
+
* @constant
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
BACHS_API_KEY: {
|
|
10
|
+
type: 'string',
|
|
11
|
+
friendlyName: 'API Key',
|
|
12
|
+
description: 'A valid Bachs secret API key.',
|
|
13
|
+
protect: true,
|
|
14
|
+
whereToGet: {
|
|
15
|
+
url: 'https://bachs.io',
|
|
16
|
+
description: 'Generate a secret key in your Bachs dashboard.'
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
BACHS_BASE_URL: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
friendlyName: 'Base URL',
|
|
22
|
+
description:
|
|
23
|
+
'Optional Bachs API base URL. Defaults from the API key environment.'
|
|
24
|
+
},
|
|
25
|
+
BACHS_WEBHOOK_SECRET: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
friendlyName: 'Webhook Secret',
|
|
28
|
+
description: 'The signing secret for your Bachs webhook destination.',
|
|
29
|
+
protect: true
|
|
30
|
+
},
|
|
31
|
+
BACHS_RETURN_URL: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
friendlyName: 'Return URL',
|
|
34
|
+
description:
|
|
35
|
+
'Default URL Bachs redirects to after checkout-session payment.'
|
|
36
|
+
},
|
|
37
|
+
BACHS_SUCCESS_URL: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
friendlyName: 'Success URL',
|
|
40
|
+
description: 'Default URL Bachs redirects to after pure checkout payment.'
|
|
41
|
+
},
|
|
42
|
+
BACHS_CANCEL_URL: {
|
|
43
|
+
type: 'string',
|
|
44
|
+
friendlyName: 'Cancel URL',
|
|
45
|
+
description: 'Default URL Bachs redirects to when checkout is cancelled.'
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
function withoutUndefined(value) {
|
|
2
|
+
if (Array.isArray(value)) {
|
|
3
|
+
return value.map(withoutUndefined)
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
if (!value || typeof value !== 'object') {
|
|
7
|
+
return value
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return Object.entries(value).reduce((memo, [key, entryValue]) => {
|
|
11
|
+
if (entryValue !== undefined) {
|
|
12
|
+
memo[key] = withoutUndefined(entryValue)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return memo
|
|
16
|
+
}, {})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function buildCustomerPayload(inputs) {
|
|
20
|
+
const checkoutData = inputs.checkoutData || {}
|
|
21
|
+
const customer = inputs.customer || {}
|
|
22
|
+
const customerId = customer.customerId || customer.id
|
|
23
|
+
|
|
24
|
+
if (customerId) {
|
|
25
|
+
return withoutUndefined({
|
|
26
|
+
customer_id: customerId
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return withoutUndefined({
|
|
31
|
+
email:
|
|
32
|
+
customer.email ||
|
|
33
|
+
inputs.customerEmail ||
|
|
34
|
+
inputs.email ||
|
|
35
|
+
checkoutData.email,
|
|
36
|
+
name:
|
|
37
|
+
customer.name || inputs.customerName || inputs.name || checkoutData.name,
|
|
38
|
+
phone_number: customer.phoneNumber || inputs.phoneNumber
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildProductCart(items) {
|
|
43
|
+
if (!items) {
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return items.map((item) =>
|
|
48
|
+
withoutUndefined({
|
|
49
|
+
product_id: item.product || item.productId,
|
|
50
|
+
quantity: item.quantity,
|
|
51
|
+
amount: item.amount
|
|
52
|
+
})
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildCheckoutSessionPayload(inputs, adapterConfig = {}) {
|
|
57
|
+
const productCollectionId =
|
|
58
|
+
inputs.productCollectionId || inputs.productCollection
|
|
59
|
+
const checkoutData = inputs.checkoutData || {}
|
|
60
|
+
const returnUrl =
|
|
61
|
+
inputs.returnUrl ||
|
|
62
|
+
inputs.successUrl ||
|
|
63
|
+
adapterConfig.returnUrl ||
|
|
64
|
+
adapterConfig.successUrl
|
|
65
|
+
|
|
66
|
+
return withoutUndefined({
|
|
67
|
+
customer: buildCustomerPayload(inputs),
|
|
68
|
+
product_cart: buildProductCart(inputs.items),
|
|
69
|
+
product_collection_id: productCollectionId,
|
|
70
|
+
billing_currency: inputs.billingCurrency,
|
|
71
|
+
allowed_payment_method_types: inputs.allowedPaymentMethodTypes,
|
|
72
|
+
return_url: returnUrl,
|
|
73
|
+
cancel_url: inputs.cancelUrl || adapterConfig.cancelUrl,
|
|
74
|
+
reference: inputs.reference,
|
|
75
|
+
metadata: inputs.metadata || checkoutData.custom
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildPricingPayload(inputs) {
|
|
80
|
+
let pricing
|
|
81
|
+
|
|
82
|
+
if (inputs.pricing) {
|
|
83
|
+
const { currencyOptions, ...pricingInput } = inputs.pricing
|
|
84
|
+
|
|
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
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return Object.keys(pricing).length > 0 ? pricing : undefined
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildPureCheckoutPayload(inputs, adapterConfig = {}) {
|
|
101
|
+
const checkoutData = inputs.checkoutData || {}
|
|
102
|
+
const customer = inputs.customer || {}
|
|
103
|
+
|
|
104
|
+
return withoutUndefined({
|
|
105
|
+
pricing: buildPricingPayload(inputs),
|
|
106
|
+
customer_email:
|
|
107
|
+
inputs.customerEmail ||
|
|
108
|
+
inputs.email ||
|
|
109
|
+
customer.email ||
|
|
110
|
+
checkoutData.email,
|
|
111
|
+
customer_name:
|
|
112
|
+
inputs.customerName || inputs.name || customer.name || checkoutData.name,
|
|
113
|
+
success_url:
|
|
114
|
+
inputs.successUrl ||
|
|
115
|
+
inputs.returnUrl ||
|
|
116
|
+
adapterConfig.successUrl ||
|
|
117
|
+
adapterConfig.returnUrl,
|
|
118
|
+
cancel_url: inputs.cancelUrl || adapterConfig.cancelUrl,
|
|
119
|
+
reference: inputs.reference,
|
|
120
|
+
metadata: inputs.metadata || checkoutData.custom,
|
|
121
|
+
expires_in_minutes: inputs.expiresInMinutes,
|
|
122
|
+
simulated_outcome: inputs.simulatedOutcome
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function buildRefundPayload(inputs) {
|
|
127
|
+
return withoutUndefined({
|
|
128
|
+
charge_id: inputs.chargeId,
|
|
129
|
+
reference: inputs.reference,
|
|
130
|
+
refund_address: inputs.refundAddress,
|
|
131
|
+
amount: inputs.amount,
|
|
132
|
+
fee_bearer: inputs.feeBearer,
|
|
133
|
+
reason: inputs.reason,
|
|
134
|
+
idempotency_key: inputs.idempotencyKey,
|
|
135
|
+
simulated_outcome: inputs.simulatedOutcome
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = {
|
|
140
|
+
buildCheckoutSessionPayload,
|
|
141
|
+
buildPureCheckoutPayload,
|
|
142
|
+
buildRefundPayload,
|
|
143
|
+
buildProductCart,
|
|
144
|
+
buildCustomerPayload,
|
|
145
|
+
withoutUndefined
|
|
146
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const fetch = require('../../helpers/fetch')
|
|
2
|
+
const parameters = require('../../helpers/parameters')
|
|
3
|
+
|
|
4
|
+
module.exports = require('machine').build({
|
|
5
|
+
friendlyName: 'Get checkout',
|
|
6
|
+
description: 'Retrieves a Bachs checkout by checkout ID.',
|
|
7
|
+
moreInfoUrl: 'https://docs.bachs.io/api-reference/checkouts/get-checkout',
|
|
8
|
+
inputs: {
|
|
9
|
+
apiKey: parameters.BACHS_API_KEY,
|
|
10
|
+
baseUrl: parameters.BACHS_BASE_URL,
|
|
11
|
+
checkoutId: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
required: true,
|
|
14
|
+
description: 'The Bachs checkout ID.'
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
exits: {
|
|
18
|
+
success: {
|
|
19
|
+
description: 'The Bachs checkout.',
|
|
20
|
+
outputVariableName: 'checkout',
|
|
21
|
+
outputType: 'ref'
|
|
22
|
+
},
|
|
23
|
+
couldNotGetCheckout: {
|
|
24
|
+
description: 'Checkout could not be retrieved.',
|
|
25
|
+
outputVariableName: 'errors',
|
|
26
|
+
outputType: 'ref'
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
fn: async function ({ apiKey, baseUrl, checkoutId }, exits) {
|
|
30
|
+
const adapterConfig = require('../../adapter').config
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const checkout = await fetch(
|
|
34
|
+
`/checkouts/${encodeURIComponent(checkoutId)}`,
|
|
35
|
+
{
|
|
36
|
+
method: 'GET',
|
|
37
|
+
apiKey: apiKey || adapterConfig.apiKey,
|
|
38
|
+
baseUrl: baseUrl || adapterConfig.baseUrl
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return exits.success(checkout)
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return exits.couldNotGetCheckout(error.bachs || error)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
})
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
const fetch = require('../helpers/fetch')
|
|
2
|
+
const {
|
|
3
|
+
buildCheckoutSessionPayload,
|
|
4
|
+
buildPureCheckoutPayload
|
|
5
|
+
} = require('../helpers/payloads')
|
|
6
|
+
const parameters = require('../helpers/parameters')
|
|
7
|
+
|
|
8
|
+
module.exports = require('machine').build({
|
|
9
|
+
friendlyName: 'Checkout',
|
|
10
|
+
description:
|
|
11
|
+
'Creates and returns a Bachs hosted checkout URL using products or pricing.',
|
|
12
|
+
moreInfoUrl: 'https://docs.bachs.io/guides/checkout/checkout-sessions',
|
|
13
|
+
inputs: {
|
|
14
|
+
apiKey: parameters.BACHS_API_KEY,
|
|
15
|
+
baseUrl: parameters.BACHS_BASE_URL,
|
|
16
|
+
items: {
|
|
17
|
+
type: 'ref',
|
|
18
|
+
description:
|
|
19
|
+
'Product items for Bachs Checkout Sessions. Each item should use product or productId plus optional quantity and amount.'
|
|
20
|
+
},
|
|
21
|
+
productCollectionId: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description:
|
|
24
|
+
'Bachs product collection ID for selection-mode checkout sessions.'
|
|
25
|
+
},
|
|
26
|
+
productCollection: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description:
|
|
29
|
+
'Alias for productCollectionId. Bachs maps this to product_collection_id.'
|
|
30
|
+
},
|
|
31
|
+
billingCurrency: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description:
|
|
34
|
+
'Currency code used to select a product price row for checkout sessions.'
|
|
35
|
+
},
|
|
36
|
+
allowedPaymentMethodTypes: {
|
|
37
|
+
type: 'ref',
|
|
38
|
+
description: 'Payment method allowlist, e.g. ["bank_transfer", "card"].'
|
|
39
|
+
},
|
|
40
|
+
returnUrl: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
description:
|
|
43
|
+
'URL Bachs redirects to after a checkout-session payment. Maps to return_url.'
|
|
44
|
+
},
|
|
45
|
+
successUrl: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description:
|
|
48
|
+
'URL Bachs redirects to after a pure checkout payment. Maps to success_url.'
|
|
49
|
+
},
|
|
50
|
+
cancelUrl: parameters.BACHS_CANCEL_URL,
|
|
51
|
+
customer: {
|
|
52
|
+
type: 'ref',
|
|
53
|
+
description:
|
|
54
|
+
'Customer details. Use customerId for existing customers or email/name/phoneNumber for new customers.'
|
|
55
|
+
},
|
|
56
|
+
customerEmail: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
description: "Customer's email address."
|
|
59
|
+
},
|
|
60
|
+
customerName: {
|
|
61
|
+
type: 'string',
|
|
62
|
+
description: "Customer's full name."
|
|
63
|
+
},
|
|
64
|
+
phoneNumber: {
|
|
65
|
+
type: 'string',
|
|
66
|
+
description: "Customer's phone number."
|
|
67
|
+
},
|
|
68
|
+
email: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
description: 'Compatibility alias for customerEmail.'
|
|
71
|
+
},
|
|
72
|
+
name: {
|
|
73
|
+
type: 'string',
|
|
74
|
+
description: 'Compatibility alias for customerName.'
|
|
75
|
+
},
|
|
76
|
+
reference: {
|
|
77
|
+
type: 'string',
|
|
78
|
+
description:
|
|
79
|
+
'Merchant reference for the checkout. Bachs requires this to be unique per organization when supplied.'
|
|
80
|
+
},
|
|
81
|
+
metadata: {
|
|
82
|
+
type: 'ref',
|
|
83
|
+
description: 'Metadata returned in Bachs webhook payloads.'
|
|
84
|
+
},
|
|
85
|
+
checkoutData: {
|
|
86
|
+
type: 'ref',
|
|
87
|
+
description:
|
|
88
|
+
'Compatibility shape for checkoutData.email, checkoutData.name, and checkoutData.custom.'
|
|
89
|
+
},
|
|
90
|
+
idempotencyKey: {
|
|
91
|
+
type: 'string',
|
|
92
|
+
description:
|
|
93
|
+
'Optional Idempotency-Key header. Defaults to reference when available.'
|
|
94
|
+
},
|
|
95
|
+
pricing: {
|
|
96
|
+
type: 'ref',
|
|
97
|
+
description: 'Pure Checkout pricing object.'
|
|
98
|
+
},
|
|
99
|
+
amount: {
|
|
100
|
+
type: 'string',
|
|
101
|
+
description: 'Pure Checkout amount, e.g. "50.00".'
|
|
102
|
+
},
|
|
103
|
+
currency: {
|
|
104
|
+
type: 'string',
|
|
105
|
+
description: 'Pure Checkout currency, e.g. "USD" or "NGN".'
|
|
106
|
+
},
|
|
107
|
+
currencyOptions: {
|
|
108
|
+
type: 'ref',
|
|
109
|
+
description: 'Pure Checkout per-currency amount overrides.'
|
|
110
|
+
},
|
|
111
|
+
expiresInMinutes: {
|
|
112
|
+
type: 'number',
|
|
113
|
+
description: 'Pure Checkout expiry in minutes.'
|
|
114
|
+
},
|
|
115
|
+
simulatedOutcome: {
|
|
116
|
+
type: 'string',
|
|
117
|
+
description: 'Sandbox-only forced outcome.'
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
exits: {
|
|
121
|
+
success: {
|
|
122
|
+
description: 'The hosted checkout URL.',
|
|
123
|
+
outputVariableName: 'checkoutUrl',
|
|
124
|
+
outputType: 'string'
|
|
125
|
+
},
|
|
126
|
+
invalidRequest: {
|
|
127
|
+
description: 'The Bachs checkout request is missing required input.',
|
|
128
|
+
outputVariableName: 'errors',
|
|
129
|
+
outputType: 'ref'
|
|
130
|
+
},
|
|
131
|
+
couldNotCreateCheckoutUrl: {
|
|
132
|
+
description: 'Checkout URL could not be created.',
|
|
133
|
+
outputVariableName: 'errors',
|
|
134
|
+
outputType: 'ref'
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
fn: async function (inputs, exits) {
|
|
138
|
+
const adapterConfig = require('../adapter').config
|
|
139
|
+
const hasItems = Array.isArray(inputs.items) && inputs.items.length > 0
|
|
140
|
+
const hasProductCollection = Boolean(
|
|
141
|
+
inputs.productCollectionId || inputs.productCollection
|
|
142
|
+
)
|
|
143
|
+
const shouldUseCheckoutSession = hasItems || hasProductCollection
|
|
144
|
+
const path = shouldUseCheckoutSession ? '/checkout-sessions' : '/checkouts'
|
|
145
|
+
const payload = shouldUseCheckoutSession
|
|
146
|
+
? buildCheckoutSessionPayload(inputs, adapterConfig)
|
|
147
|
+
: buildPureCheckoutPayload(inputs, adapterConfig)
|
|
148
|
+
|
|
149
|
+
if (shouldUseCheckoutSession && hasItems === hasProductCollection) {
|
|
150
|
+
return exits.invalidRequest({
|
|
151
|
+
message:
|
|
152
|
+
'Provide exactly one of items or productCollectionId for a Bachs checkout session.'
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!shouldUseCheckoutSession && !payload.pricing) {
|
|
157
|
+
return exits.invalidRequest({
|
|
158
|
+
message:
|
|
159
|
+
'Provide product items/productCollectionId or pure checkout pricing.'
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const checkout = await fetch(path, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
apiKey: inputs.apiKey || adapterConfig.apiKey,
|
|
167
|
+
baseUrl: inputs.baseUrl || adapterConfig.baseUrl,
|
|
168
|
+
idempotencyKey: inputs.idempotencyKey || inputs.reference,
|
|
169
|
+
body: payload
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
if (!checkout || !checkout.checkout_url) {
|
|
173
|
+
return exits.couldNotCreateCheckoutUrl(checkout)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return exits.success(checkout.checkout_url)
|
|
177
|
+
} catch (error) {
|
|
178
|
+
return exits.couldNotCreateCheckoutUrl(error.bachs || error)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const checkout = require('./checkout')
|
|
2
|
+
|
|
3
|
+
checkout.get = require('./checkout/get')
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
checkout,
|
|
7
|
+
verify: require('./verify'),
|
|
8
|
+
webhooks: {
|
|
9
|
+
verify: require('./webhooks/verify')
|
|
10
|
+
},
|
|
11
|
+
refund: {
|
|
12
|
+
create: require('./refund/create')
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const fetch = require('../../helpers/fetch')
|
|
2
|
+
const { buildRefundPayload } = require('../../helpers/payloads')
|
|
3
|
+
const parameters = require('../../helpers/parameters')
|
|
4
|
+
|
|
5
|
+
module.exports = require('machine').build({
|
|
6
|
+
friendlyName: 'Create refund',
|
|
7
|
+
description: 'Creates a Bachs refund for a completed payment.',
|
|
8
|
+
moreInfoUrl: 'https://docs.bachs.io/api-reference/refunds/create-refund',
|
|
9
|
+
inputs: {
|
|
10
|
+
apiKey: parameters.BACHS_API_KEY,
|
|
11
|
+
baseUrl: parameters.BACHS_BASE_URL,
|
|
12
|
+
chargeId: {
|
|
13
|
+
type: 'string',
|
|
14
|
+
required: true,
|
|
15
|
+
description: 'The Bachs charge ID to refund.'
|
|
16
|
+
},
|
|
17
|
+
reference: {
|
|
18
|
+
type: 'string',
|
|
19
|
+
required: true,
|
|
20
|
+
description:
|
|
21
|
+
'Your unique refund reference. Bachs requires this per organization and environment.'
|
|
22
|
+
},
|
|
23
|
+
refundAddress: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
description: 'Destination wallet address for crypto refunds.'
|
|
26
|
+
},
|
|
27
|
+
amount: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
description:
|
|
30
|
+
'Optional partial refund amount in the charge settlement currency.'
|
|
31
|
+
},
|
|
32
|
+
feeBearer: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
description: 'Who bears the refund fee: org or customer.'
|
|
35
|
+
},
|
|
36
|
+
reason: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
description: 'Human-readable reason for the refund.'
|
|
39
|
+
},
|
|
40
|
+
idempotencyKey: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
description:
|
|
43
|
+
'Optional idempotency key. Sent as both body idempotency_key and Idempotency-Key header.'
|
|
44
|
+
},
|
|
45
|
+
simulatedOutcome: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description: 'Sandbox-only forced refund outcome.'
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
exits: {
|
|
51
|
+
success: {
|
|
52
|
+
description: 'The Bachs refund.',
|
|
53
|
+
outputVariableName: 'refund',
|
|
54
|
+
outputType: 'ref'
|
|
55
|
+
},
|
|
56
|
+
couldNotCreateRefund: {
|
|
57
|
+
description: 'Refund could not be created.',
|
|
58
|
+
outputVariableName: 'errors',
|
|
59
|
+
outputType: 'ref'
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
fn: async function (inputs, exits) {
|
|
63
|
+
const adapterConfig = require('../../adapter').config
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const refund = await fetch('/payments/refunds', {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
apiKey: inputs.apiKey || adapterConfig.apiKey,
|
|
69
|
+
baseUrl: inputs.baseUrl || adapterConfig.baseUrl,
|
|
70
|
+
idempotencyKey: inputs.idempotencyKey || inputs.reference,
|
|
71
|
+
body: buildRefundPayload(inputs)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
return exits.success(refund)
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return exits.couldNotCreateRefund(error.bachs || error)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const fetch = require('../helpers/fetch')
|
|
2
|
+
const parameters = require('../helpers/parameters')
|
|
3
|
+
|
|
4
|
+
module.exports = require('machine').build({
|
|
5
|
+
friendlyName: 'Verify charge',
|
|
6
|
+
description: 'Retrieves the status and details of a Bachs charge.',
|
|
7
|
+
moreInfoUrl: 'https://docs.bachs.io/guides/payments/get-charge-status',
|
|
8
|
+
inputs: {
|
|
9
|
+
apiKey: parameters.BACHS_API_KEY,
|
|
10
|
+
baseUrl: parameters.BACHS_BASE_URL,
|
|
11
|
+
chargeId: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
required: true,
|
|
14
|
+
description: 'The Bachs charge ID.'
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
exits: {
|
|
18
|
+
success: {
|
|
19
|
+
description: 'The Bachs charge.',
|
|
20
|
+
outputVariableName: 'charge',
|
|
21
|
+
outputType: 'ref'
|
|
22
|
+
},
|
|
23
|
+
couldNotVerifyCharge: {
|
|
24
|
+
description: 'Charge could not be verified.',
|
|
25
|
+
outputVariableName: 'errors',
|
|
26
|
+
outputType: 'ref'
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
fn: async function ({ apiKey, baseUrl, chargeId }, exits) {
|
|
30
|
+
const adapterConfig = require('../adapter').config
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const charge = await fetch(
|
|
34
|
+
`/payments/charges/${encodeURIComponent(chargeId)}`,
|
|
35
|
+
{
|
|
36
|
+
method: 'GET',
|
|
37
|
+
apiKey: apiKey || adapterConfig.apiKey,
|
|
38
|
+
baseUrl: baseUrl || adapterConfig.baseUrl
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return exits.success(charge)
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return exits.couldNotVerifyCharge(error.bachs || error)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const crypto = require('crypto')
|
|
2
|
+
const parameters = require('../../helpers/parameters')
|
|
3
|
+
|
|
4
|
+
function normalizeRawBody(rawBody) {
|
|
5
|
+
if (Buffer.isBuffer(rawBody)) {
|
|
6
|
+
return rawBody.toString('utf8')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
return rawBody
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function safelyCompare(expected, received) {
|
|
13
|
+
const expectedBuffer = Buffer.from(expected)
|
|
14
|
+
const receivedBuffer = Buffer.from(received)
|
|
15
|
+
|
|
16
|
+
if (expectedBuffer.length !== receivedBuffer.length) {
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return crypto.timingSafeEqual(expectedBuffer, receivedBuffer)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = require('machine').build({
|
|
24
|
+
friendlyName: 'Verify Bachs webhook signature',
|
|
25
|
+
description: 'Verifies X-Bachs-Timestamp and X-Bachs-Signature.',
|
|
26
|
+
moreInfoUrl: 'https://docs.bachs.io/guides/webhooks/overview',
|
|
27
|
+
inputs: {
|
|
28
|
+
webhookSecret: parameters.BACHS_WEBHOOK_SECRET,
|
|
29
|
+
rawBody: {
|
|
30
|
+
type: 'ref',
|
|
31
|
+
required: true,
|
|
32
|
+
description: 'The exact raw request body string or Buffer.'
|
|
33
|
+
},
|
|
34
|
+
timestamp: {
|
|
35
|
+
type: 'string',
|
|
36
|
+
required: true,
|
|
37
|
+
description: 'The X-Bachs-Timestamp header value.'
|
|
38
|
+
},
|
|
39
|
+
signature: {
|
|
40
|
+
type: 'string',
|
|
41
|
+
required: true,
|
|
42
|
+
description: 'The X-Bachs-Signature header value.'
|
|
43
|
+
},
|
|
44
|
+
toleranceSeconds: {
|
|
45
|
+
type: 'number',
|
|
46
|
+
defaultsTo: 300,
|
|
47
|
+
description: 'Maximum allowed timestamp skew in seconds.'
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
exits: {
|
|
51
|
+
success: {
|
|
52
|
+
description: 'The signature is valid.',
|
|
53
|
+
outputVariableName: 'valid',
|
|
54
|
+
outputType: 'boolean'
|
|
55
|
+
},
|
|
56
|
+
invalidSignature: {
|
|
57
|
+
description: 'The signature is missing, stale, or invalid.',
|
|
58
|
+
outputVariableName: 'error',
|
|
59
|
+
outputType: 'ref'
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
fn: async function (
|
|
63
|
+
{ webhookSecret, rawBody, timestamp, signature, toleranceSeconds },
|
|
64
|
+
exits
|
|
65
|
+
) {
|
|
66
|
+
const adapterConfig = require('../../adapter').config
|
|
67
|
+
const secret = webhookSecret || adapterConfig.webhookSecret
|
|
68
|
+
|
|
69
|
+
if (!secret || !timestamp || !signature) {
|
|
70
|
+
return exits.invalidSignature({
|
|
71
|
+
message: 'Missing Bachs webhook signing data.'
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const parsedTimestamp = Number.parseInt(timestamp, 10)
|
|
76
|
+
|
|
77
|
+
if (!Number.isFinite(parsedTimestamp)) {
|
|
78
|
+
return exits.invalidSignature({
|
|
79
|
+
message: 'Invalid Bachs webhook timestamp.'
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (Math.abs(Date.now() / 1000 - parsedTimestamp) > toleranceSeconds) {
|
|
84
|
+
return exits.invalidSignature({
|
|
85
|
+
message: 'Stale Bachs webhook timestamp.'
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const rawBodyString = normalizeRawBody(rawBody)
|
|
90
|
+
const expected = crypto
|
|
91
|
+
.createHmac('sha256', secret)
|
|
92
|
+
.update(`${timestamp}.${rawBodyString}`, 'utf8')
|
|
93
|
+
.digest('hex')
|
|
94
|
+
|
|
95
|
+
if (!safelyCompare(expected, signature)) {
|
|
96
|
+
return exits.invalidSignature({
|
|
97
|
+
message: 'Invalid Bachs webhook signature.'
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return exits.success(true)
|
|
102
|
+
}
|
|
103
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sails-pay/bachs",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Bachs adapter for Sails Pay",
|
|
5
|
+
"main": "adapter.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/sailscastshq/sails-pay#readme",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/sailscastshq/sails-pay.git",
|
|
13
|
+
"directory": "packages/sails-bachs"
|
|
14
|
+
},
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/sailscastshq/sails-pay/issues"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"Bachs",
|
|
20
|
+
"Sails",
|
|
21
|
+
"Pay",
|
|
22
|
+
"Payments",
|
|
23
|
+
"payment-gateway",
|
|
24
|
+
"online-payments",
|
|
25
|
+
"sails",
|
|
26
|
+
"sails.js",
|
|
27
|
+
"payment-integration"
|
|
28
|
+
],
|
|
29
|
+
"author": "Kelvin Omereshone <kelvin@sailscasts.com>",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"machine": "^15.2.3",
|
|
36
|
+
"undici": "^6.19.2"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const test = require('node:test')
|
|
2
|
+
const assert = require('node:assert/strict')
|
|
3
|
+
const adapter = require('../adapter')
|
|
4
|
+
const checkout = require('../machines/checkout')
|
|
5
|
+
const fetch = require('../helpers/fetch')
|
|
6
|
+
|
|
7
|
+
test('checkout creates a Bachs checkout session from camelCase inputs', async () => {
|
|
8
|
+
const calls = []
|
|
9
|
+
|
|
10
|
+
fetch.setFetchImplementation(async (url, options) => {
|
|
11
|
+
calls.push({ url, options })
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
ok: true,
|
|
15
|
+
status: 201,
|
|
16
|
+
statusText: 'Created',
|
|
17
|
+
text: async () =>
|
|
18
|
+
JSON.stringify({
|
|
19
|
+
checkout_id: 'chk_123',
|
|
20
|
+
checkout_url: 'https://pay.bachs.io/c/test'
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const checkoutUrl = await checkout({
|
|
26
|
+
apiKey: 'sk_sandbox_123',
|
|
27
|
+
items: [{ product: 'prod_abc123', quantity: 1 }],
|
|
28
|
+
customer: {
|
|
29
|
+
email: 'customer@example.com',
|
|
30
|
+
name: 'Jane Doe'
|
|
31
|
+
},
|
|
32
|
+
returnUrl: 'https://example.com/return',
|
|
33
|
+
cancelUrl: 'https://example.com/cancel',
|
|
34
|
+
reference: 'order_123'
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
assert.equal(checkoutUrl, 'https://pay.bachs.io/c/test')
|
|
38
|
+
assert.equal(
|
|
39
|
+
calls[0].url,
|
|
40
|
+
'https://sandbox-api.bachs.io/v1/checkout-sessions'
|
|
41
|
+
)
|
|
42
|
+
assert.equal(calls[0].options.headers['Idempotency-Key'], 'order_123')
|
|
43
|
+
assert.deepEqual(JSON.parse(calls[0].options.body), {
|
|
44
|
+
customer: {
|
|
45
|
+
email: 'customer@example.com',
|
|
46
|
+
name: 'Jane Doe'
|
|
47
|
+
},
|
|
48
|
+
product_cart: [
|
|
49
|
+
{
|
|
50
|
+
product_id: 'prod_abc123',
|
|
51
|
+
quantity: 1
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
return_url: 'https://example.com/return',
|
|
55
|
+
cancel_url: 'https://example.com/cancel',
|
|
56
|
+
reference: 'order_123'
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
fetch.resetFetchImplementation()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('adapter exposes checkout.get as the uniform checkout lookup API', async () => {
|
|
63
|
+
const calls = []
|
|
64
|
+
|
|
65
|
+
fetch.setFetchImplementation(async (url, options) => {
|
|
66
|
+
calls.push({ url, options })
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
status: 200,
|
|
71
|
+
statusText: 'OK',
|
|
72
|
+
text: async () =>
|
|
73
|
+
JSON.stringify({
|
|
74
|
+
checkout_id: 'chk_123',
|
|
75
|
+
status: 'COMPLETED',
|
|
76
|
+
charge: {
|
|
77
|
+
charge_id: 'chr_123',
|
|
78
|
+
status: 'succeeded'
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const result = await adapter.checkout.get({
|
|
85
|
+
apiKey: 'sk_sandbox_123',
|
|
86
|
+
checkoutId: 'chk_123'
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
assert.equal(typeof adapter.checkout, 'function')
|
|
90
|
+
assert.equal(typeof adapter.checkout.get, 'function')
|
|
91
|
+
assert.equal(result.checkout_id, 'chk_123')
|
|
92
|
+
assert.equal(result.charge.charge_id, 'chr_123')
|
|
93
|
+
assert.equal(
|
|
94
|
+
calls[0].url,
|
|
95
|
+
'https://sandbox-api.bachs.io/v1/checkouts/chk_123'
|
|
96
|
+
)
|
|
97
|
+
assert.equal(calls[0].options.method, 'GET')
|
|
98
|
+
|
|
99
|
+
fetch.resetFetchImplementation()
|
|
100
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const test = require('node:test')
|
|
2
|
+
const assert = require('node:assert/strict')
|
|
3
|
+
const fetch = require('../helpers/fetch')
|
|
4
|
+
|
|
5
|
+
test('resolveBaseUrl uses configured URL first', () => {
|
|
6
|
+
assert.equal(
|
|
7
|
+
fetch.resolveBaseUrl({
|
|
8
|
+
apiKey: 'sk_sandbox_123',
|
|
9
|
+
baseUrl: 'https://example.test'
|
|
10
|
+
}),
|
|
11
|
+
'https://example.test'
|
|
12
|
+
)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('resolveBaseUrl derives sandbox URL from sandbox keys', () => {
|
|
16
|
+
assert.equal(
|
|
17
|
+
fetch.resolveBaseUrl({ apiKey: 'sk_sandbox_123' }),
|
|
18
|
+
'https://sandbox-api.bachs.io'
|
|
19
|
+
)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('resolveBaseUrl defaults to live URL', () => {
|
|
23
|
+
assert.equal(
|
|
24
|
+
fetch.resolveBaseUrl({ apiKey: 'sk_live_123' }),
|
|
25
|
+
'https://api.bachs.io'
|
|
26
|
+
)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('fetch prefixes /v1, sends auth, idempotency, and JSON body', async () => {
|
|
30
|
+
const calls = []
|
|
31
|
+
|
|
32
|
+
fetch.setFetchImplementation(async (url, options) => {
|
|
33
|
+
calls.push({ url, options })
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
ok: true,
|
|
37
|
+
status: 201,
|
|
38
|
+
statusText: 'Created',
|
|
39
|
+
text: async () =>
|
|
40
|
+
JSON.stringify({ checkout_url: 'https://pay.bachs.io/c/test' })
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const response = await fetch('/checkout-sessions', {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
apiKey: 'sk_sandbox_123',
|
|
47
|
+
idempotencyKey: 'order_123',
|
|
48
|
+
body: {
|
|
49
|
+
reference: 'order_123'
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
assert.deepEqual(response, {
|
|
54
|
+
checkout_url: 'https://pay.bachs.io/c/test'
|
|
55
|
+
})
|
|
56
|
+
assert.equal(
|
|
57
|
+
calls[0].url,
|
|
58
|
+
'https://sandbox-api.bachs.io/v1/checkout-sessions'
|
|
59
|
+
)
|
|
60
|
+
assert.equal(calls[0].options.method, 'POST')
|
|
61
|
+
assert.equal(calls[0].options.headers.Authorization, 'Bearer sk_sandbox_123')
|
|
62
|
+
assert.equal(calls[0].options.headers['Idempotency-Key'], 'order_123')
|
|
63
|
+
assert.equal(calls[0].options.body, '{"reference":"order_123"}')
|
|
64
|
+
|
|
65
|
+
fetch.resetFetchImplementation()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('fetch normalizes non-2xx Bachs errors', async () => {
|
|
69
|
+
fetch.setFetchImplementation(async () => ({
|
|
70
|
+
ok: false,
|
|
71
|
+
status: 400,
|
|
72
|
+
statusText: 'Bad Request',
|
|
73
|
+
text: async () =>
|
|
74
|
+
JSON.stringify({
|
|
75
|
+
detail: 'Invalid request parameters',
|
|
76
|
+
error_code: 'VALIDATION_ERROR',
|
|
77
|
+
errors: [
|
|
78
|
+
{
|
|
79
|
+
field: 'customer.email',
|
|
80
|
+
message: 'Invalid email',
|
|
81
|
+
type: 'value_error'
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
})
|
|
85
|
+
}))
|
|
86
|
+
|
|
87
|
+
await assert.rejects(
|
|
88
|
+
() =>
|
|
89
|
+
fetch('/checkout-sessions', {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
apiKey: 'sk_sandbox_123',
|
|
92
|
+
body: {}
|
|
93
|
+
}),
|
|
94
|
+
(error) => {
|
|
95
|
+
assert.equal(error.bachs.statusCode, 400)
|
|
96
|
+
assert.equal(error.bachs.code, 'VALIDATION_ERROR')
|
|
97
|
+
assert.equal(error.bachs.message, 'Invalid request parameters')
|
|
98
|
+
assert.equal(error.bachs.errors[0].field, 'customer.email')
|
|
99
|
+
return true
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
fetch.resetFetchImplementation()
|
|
104
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
const test = require('node:test')
|
|
2
|
+
const assert = require('node:assert/strict')
|
|
3
|
+
const {
|
|
4
|
+
buildCheckoutSessionPayload,
|
|
5
|
+
buildPureCheckoutPayload,
|
|
6
|
+
buildRefundPayload
|
|
7
|
+
} = require('../helpers/payloads')
|
|
8
|
+
|
|
9
|
+
test('buildCheckoutSessionPayload maps product checkout inputs to Bachs snake case', () => {
|
|
10
|
+
const payload = buildCheckoutSessionPayload(
|
|
11
|
+
{
|
|
12
|
+
items: [{ product: 'prod_abc123', quantity: 2, amount: '50.00' }],
|
|
13
|
+
customer: {
|
|
14
|
+
email: 'customer@example.com',
|
|
15
|
+
name: 'Jane Doe',
|
|
16
|
+
phoneNumber: '+2348012345678'
|
|
17
|
+
},
|
|
18
|
+
billingCurrency: 'NGN',
|
|
19
|
+
allowedPaymentMethodTypes: ['bank_transfer', 'card'],
|
|
20
|
+
returnUrl: 'https://example.com/return',
|
|
21
|
+
cancelUrl: 'https://example.com/cancel',
|
|
22
|
+
reference: 'order_9876',
|
|
23
|
+
metadata: {
|
|
24
|
+
orderId: '9876'
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
{}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
assert.deepEqual(payload, {
|
|
31
|
+
customer: {
|
|
32
|
+
email: 'customer@example.com',
|
|
33
|
+
name: 'Jane Doe',
|
|
34
|
+
phone_number: '+2348012345678'
|
|
35
|
+
},
|
|
36
|
+
product_cart: [
|
|
37
|
+
{
|
|
38
|
+
product_id: 'prod_abc123',
|
|
39
|
+
quantity: 2,
|
|
40
|
+
amount: '50.00'
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
billing_currency: 'NGN',
|
|
44
|
+
allowed_payment_method_types: ['bank_transfer', 'card'],
|
|
45
|
+
return_url: 'https://example.com/return',
|
|
46
|
+
cancel_url: 'https://example.com/cancel',
|
|
47
|
+
reference: 'order_9876',
|
|
48
|
+
metadata: {
|
|
49
|
+
orderId: '9876'
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('buildCheckoutSessionPayload maps productCollectionId and configured return URL', () => {
|
|
55
|
+
const payload = buildCheckoutSessionPayload(
|
|
56
|
+
{
|
|
57
|
+
productCollectionId: 'pgrp_123',
|
|
58
|
+
customer: {
|
|
59
|
+
customerId: 'cust_123'
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
returnUrl: 'https://example.com/after',
|
|
64
|
+
cancelUrl: 'https://example.com/cancel'
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
assert.deepEqual(payload, {
|
|
69
|
+
customer: {
|
|
70
|
+
customer_id: 'cust_123'
|
|
71
|
+
},
|
|
72
|
+
product_collection_id: 'pgrp_123',
|
|
73
|
+
return_url: 'https://example.com/after',
|
|
74
|
+
cancel_url: 'https://example.com/cancel'
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('buildPureCheckoutPayload maps amount checkout inputs to Bachs snake case', () => {
|
|
79
|
+
const payload = buildPureCheckoutPayload(
|
|
80
|
+
{
|
|
81
|
+
amount: '50.00',
|
|
82
|
+
currency: 'USD',
|
|
83
|
+
currencyOptions: {
|
|
84
|
+
NGN: '75000.00'
|
|
85
|
+
},
|
|
86
|
+
email: 'customer@example.com',
|
|
87
|
+
name: 'Jane Doe',
|
|
88
|
+
successUrl: 'https://example.com/success',
|
|
89
|
+
cancelUrl: 'https://example.com/cancel',
|
|
90
|
+
reference: 'order_9876',
|
|
91
|
+
metadata: {
|
|
92
|
+
orderId: '9876'
|
|
93
|
+
},
|
|
94
|
+
expiresInMinutes: 30,
|
|
95
|
+
simulatedOutcome: 'success'
|
|
96
|
+
},
|
|
97
|
+
{}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
assert.deepEqual(payload, {
|
|
101
|
+
pricing: {
|
|
102
|
+
currency: 'USD',
|
|
103
|
+
amount: '50.00',
|
|
104
|
+
currency_options: {
|
|
105
|
+
NGN: '75000.00'
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
customer_email: 'customer@example.com',
|
|
109
|
+
customer_name: 'Jane Doe',
|
|
110
|
+
success_url: 'https://example.com/success',
|
|
111
|
+
cancel_url: 'https://example.com/cancel',
|
|
112
|
+
reference: 'order_9876',
|
|
113
|
+
metadata: {
|
|
114
|
+
orderId: '9876'
|
|
115
|
+
},
|
|
116
|
+
expires_in_minutes: 30,
|
|
117
|
+
simulated_outcome: 'success'
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('buildRefundPayload maps refund inputs to Bachs snake case', () => {
|
|
122
|
+
const payload = buildRefundPayload({
|
|
123
|
+
chargeId: 'chr_123',
|
|
124
|
+
reference: 'refund_123',
|
|
125
|
+
refundAddress: 'wallet-address',
|
|
126
|
+
amount: '25.00',
|
|
127
|
+
feeBearer: 'org',
|
|
128
|
+
reason: 'Customer request',
|
|
129
|
+
idempotencyKey: 'refund_123',
|
|
130
|
+
simulatedOutcome: 'success'
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
assert.deepEqual(payload, {
|
|
134
|
+
charge_id: 'chr_123',
|
|
135
|
+
reference: 'refund_123',
|
|
136
|
+
refund_address: 'wallet-address',
|
|
137
|
+
amount: '25.00',
|
|
138
|
+
fee_bearer: 'org',
|
|
139
|
+
reason: 'Customer request',
|
|
140
|
+
idempotency_key: 'refund_123',
|
|
141
|
+
simulated_outcome: 'success'
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const test = require('node:test')
|
|
2
|
+
const assert = require('node:assert/strict')
|
|
3
|
+
const crypto = require('crypto')
|
|
4
|
+
const adapter = require('../adapter')
|
|
5
|
+
const verify = require('../machines/webhooks/verify')
|
|
6
|
+
|
|
7
|
+
function sign({ rawBody, secret, timestamp }) {
|
|
8
|
+
return crypto
|
|
9
|
+
.createHmac('sha256', secret)
|
|
10
|
+
.update(`${timestamp}.${rawBody}`, 'utf8')
|
|
11
|
+
.digest('hex')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test('webhooks.verify accepts a valid Bachs webhook signature', async () => {
|
|
15
|
+
const rawBody = '{"id":"evt_123","type":"collection.succeeded"}'
|
|
16
|
+
const timestamp = Math.floor(Date.now() / 1000).toString()
|
|
17
|
+
const signature = sign({
|
|
18
|
+
rawBody,
|
|
19
|
+
secret: 'whsec_123',
|
|
20
|
+
timestamp
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const valid = await verify({
|
|
24
|
+
webhookSecret: 'whsec_123',
|
|
25
|
+
rawBody,
|
|
26
|
+
timestamp,
|
|
27
|
+
signature
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
assert.equal(valid, true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('webhooks.verify rejects invalid signatures', async () => {
|
|
34
|
+
const rawBody = '{"id":"evt_123","type":"collection.succeeded"}'
|
|
35
|
+
const timestamp = Math.floor(Date.now() / 1000).toString()
|
|
36
|
+
|
|
37
|
+
await assert.rejects(
|
|
38
|
+
() =>
|
|
39
|
+
verify({
|
|
40
|
+
webhookSecret: 'whsec_123',
|
|
41
|
+
rawBody,
|
|
42
|
+
timestamp,
|
|
43
|
+
signature: 'bad-signature'
|
|
44
|
+
}),
|
|
45
|
+
(error) => {
|
|
46
|
+
assert.equal(error.exit, 'invalidSignature')
|
|
47
|
+
assert.equal(error.raw.message, 'Invalid Bachs webhook signature.')
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('webhooks.verify rejects stale timestamps', async () => {
|
|
54
|
+
const rawBody = '{"id":"evt_123","type":"collection.succeeded"}'
|
|
55
|
+
const timestamp = '100'
|
|
56
|
+
const signature = sign({
|
|
57
|
+
rawBody,
|
|
58
|
+
secret: 'whsec_123',
|
|
59
|
+
timestamp
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
await assert.rejects(
|
|
63
|
+
() =>
|
|
64
|
+
verify({
|
|
65
|
+
webhookSecret: 'whsec_123',
|
|
66
|
+
rawBody,
|
|
67
|
+
timestamp,
|
|
68
|
+
signature,
|
|
69
|
+
toleranceSeconds: 300
|
|
70
|
+
}),
|
|
71
|
+
(error) => {
|
|
72
|
+
assert.equal(error.exit, 'invalidSignature')
|
|
73
|
+
assert.equal(error.raw.message, 'Stale Bachs webhook timestamp.')
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('adapter exposes webhooks.verify as the uniform webhook verification API', () => {
|
|
80
|
+
assert.equal(typeof adapter.webhooks.verify, 'function')
|
|
81
|
+
assert.deepEqual(Object.keys(adapter.webhooks), ['verify'])
|
|
82
|
+
})
|