@sails-pay/bachs 0.0.2 → 0.0.3

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.
@@ -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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sails-pay/bachs",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Bachs adapter for Sails Pay",
5
5
  "main": "adapter.js",
6
6
  "scripts": {
@@ -4,6 +4,11 @@ const adapter = require('../adapter')
4
4
  const checkout = require('../machines/checkout')
5
5
  const fetch = require('../helpers/fetch')
6
6
 
7
+ test.afterEach(() => {
8
+ fetch.resetFetchImplementation()
9
+ adapter.config = {}
10
+ })
11
+
7
12
  test('checkout creates a Bachs checkout session from camelCase inputs', async () => {
8
13
  const calls = []
9
14
 
@@ -24,7 +29,7 @@ test('checkout creates a Bachs checkout session from camelCase inputs', async ()
24
29
 
25
30
  const checkoutUrl = await checkout({
26
31
  apiKey: 'sk_sandbox_123',
27
- items: [{ product: 'prod_abc123', quantity: 1 }],
32
+ items: [{ product: 'prod_abc123' }],
28
33
  customer: {
29
34
  email: 'customer@example.com',
30
35
  name: 'Jane Doe'
@@ -47,18 +52,280 @@ test('checkout creates a Bachs checkout session from camelCase inputs', async ()
47
52
  },
48
53
  product_cart: [
49
54
  {
50
- product_id: 'prod_abc123',
51
- quantity: 1
55
+ product_id: 'prod_abc123'
52
56
  }
53
57
  ],
54
58
  return_url: 'https://example.com/return',
55
59
  cancel_url: 'https://example.com/cancel',
56
60
  reference: 'order_123'
57
61
  })
62
+ })
58
63
 
59
- fetch.resetFetchImplementation()
64
+ test('checkout sends custom pricing and the buyer-selected amount', async () => {
65
+ const calls = []
66
+
67
+ fetch.setFetchImplementation(async (url, options) => {
68
+ calls.push({ url, options })
69
+
70
+ return {
71
+ ok: true,
72
+ status: 201,
73
+ statusText: 'Created',
74
+ text: async () =>
75
+ JSON.stringify({
76
+ checkout_url: 'https://pay.bachs.io/c/custom'
77
+ })
78
+ }
79
+ })
80
+
81
+ const checkoutUrl = await checkout({
82
+ apiKey: 'sk_sandbox_123',
83
+ items: [
84
+ {
85
+ product: 'prod_custom',
86
+ pricing: {
87
+ type: 'custom',
88
+ presetAmount: '10.00',
89
+ minimumAmount: '5.00',
90
+ maximumAmount: '100.00'
91
+ },
92
+ chosenAmount: '12.00'
93
+ }
94
+ ]
95
+ })
96
+
97
+ assert.equal(checkoutUrl, 'https://pay.bachs.io/c/custom')
98
+ assert.deepEqual(JSON.parse(calls[0].options.body), {
99
+ customer: {},
100
+ product_cart: [
101
+ {
102
+ product_id: 'prod_custom',
103
+ pricing: {
104
+ price_type: 'custom',
105
+ preset_amount: '10.00',
106
+ minimum_amount: '5.00',
107
+ maximum_amount: '100.00'
108
+ },
109
+ amount: '12.00'
110
+ }
111
+ ]
112
+ })
113
+ })
114
+
115
+ test('checkout preserves pure checkout behavior', async () => {
116
+ const calls = []
117
+
118
+ fetch.setFetchImplementation(async (url, options) => {
119
+ calls.push({ url, options })
120
+
121
+ return {
122
+ ok: true,
123
+ status: 201,
124
+ statusText: 'Created',
125
+ text: async () =>
126
+ JSON.stringify({
127
+ checkout_url: 'https://pay.bachs.io/c/pure'
128
+ })
129
+ }
130
+ })
131
+
132
+ const checkoutUrl = await checkout({
133
+ apiKey: 'sk_sandbox_123',
134
+ amount: '42.00',
135
+ currency: 'USD',
136
+ reference: 'pure_123'
137
+ })
138
+
139
+ assert.equal(checkoutUrl, 'https://pay.bachs.io/c/pure')
140
+ assert.equal(calls[0].url, 'https://sandbox-api.bachs.io/v1/checkouts')
141
+ assert.equal(calls[0].options.headers['Idempotency-Key'], 'pure_123')
142
+ assert.deepEqual(JSON.parse(calls[0].options.body), {
143
+ pricing: {
144
+ currency: 'USD',
145
+ amount: '42.00'
146
+ },
147
+ reference: 'pure_123'
148
+ })
60
149
  })
61
150
 
151
+ const invalidItemCases = [
152
+ {
153
+ name: 'amount with explicit pricing',
154
+ item: {
155
+ product: 'prod_123',
156
+ amount: '19.00',
157
+ pricing: { type: 'fixed', amount: '19.00' }
158
+ },
159
+ field: 'items[0]',
160
+ message: /both amount and pricing/
161
+ },
162
+ {
163
+ name: 'an unsupported pricing type',
164
+ item: {
165
+ product: 'prod_123',
166
+ pricing: { type: 'metered' }
167
+ },
168
+ field: 'items[0].pricing.type',
169
+ message: /fixed, custom, or free/
170
+ },
171
+ {
172
+ name: 'fixed pricing without an amount',
173
+ item: {
174
+ product: 'prod_123',
175
+ pricing: { type: 'fixed' }
176
+ },
177
+ field: 'items[0].pricing.amount',
178
+ message: /required for fixed pricing/
179
+ },
180
+ ...['presetAmount', 'minimumAmount', 'maximumAmount'].map((field) => ({
181
+ name: `fixed pricing with ${field}`,
182
+ item: {
183
+ product: 'prod_123',
184
+ pricing: {
185
+ type: 'fixed',
186
+ amount: '19.00',
187
+ [field]: '10.00'
188
+ }
189
+ },
190
+ field: `items[0].pricing.${field}`,
191
+ message: /not allowed with fixed pricing/
192
+ })),
193
+ {
194
+ name: 'custom pricing with a fixed amount',
195
+ item: {
196
+ product: 'prod_123',
197
+ pricing: {
198
+ type: 'custom',
199
+ amount: '19.00'
200
+ }
201
+ },
202
+ field: 'items[0].pricing.amount',
203
+ message: /not allowed with custom pricing/
204
+ },
205
+ ...['amount', 'presetAmount', 'minimumAmount', 'maximumAmount'].map(
206
+ (field) => ({
207
+ name: `free pricing with ${field}`,
208
+ item: {
209
+ product: 'prod_123',
210
+ pricing: {
211
+ type: 'free',
212
+ [field]: '10.00'
213
+ }
214
+ },
215
+ field: `items[0].pricing.${field}`,
216
+ message: /not allowed with free pricing/
217
+ })
218
+ ),
219
+ {
220
+ name: 'free pricing with chosenAmount',
221
+ item: {
222
+ product: 'prod_123',
223
+ pricing: { type: 'free' },
224
+ chosenAmount: '10.00'
225
+ },
226
+ field: 'items[0].chosenAmount',
227
+ message: /not allowed with free pricing/
228
+ },
229
+ {
230
+ name: 'fixed amount shorthand with chosenAmount',
231
+ item: {
232
+ product: 'prod_123',
233
+ amount: '19.00',
234
+ chosenAmount: '10.00'
235
+ },
236
+ field: 'items[0].chosenAmount',
237
+ message: /catalog custom pricing or ad-hoc custom pricing/
238
+ },
239
+ {
240
+ name: 'explicit fixed pricing with chosenAmount',
241
+ item: {
242
+ product: 'prod_123',
243
+ pricing: { type: 'fixed', amount: '19.00' },
244
+ chosenAmount: '10.00'
245
+ },
246
+ field: 'items[0].chosenAmount',
247
+ message: /catalog custom pricing or ad-hoc custom pricing/
248
+ },
249
+ {
250
+ name: 'a numeric amount shorthand',
251
+ item: {
252
+ product: 'prod_123',
253
+ amount: 1900
254
+ },
255
+ field: 'items[0].amount',
256
+ message: /decimal string/
257
+ },
258
+ {
259
+ name: 'a numeric chosenAmount',
260
+ item: {
261
+ product: 'prod_123',
262
+ chosenAmount: 1200
263
+ },
264
+ field: 'items[0].chosenAmount',
265
+ message: /decimal string/
266
+ },
267
+ {
268
+ name: 'a numeric fixed pricing amount',
269
+ item: {
270
+ product: 'prod_123',
271
+ pricing: { type: 'fixed', amount: 1900 }
272
+ },
273
+ field: 'items[0].pricing.amount',
274
+ message: /decimal string/
275
+ },
276
+ ...['presetAmount', 'minimumAmount', 'maximumAmount'].map((field) => ({
277
+ name: `a numeric custom pricing ${field}`,
278
+ item: {
279
+ product: 'prod_123',
280
+ pricing: {
281
+ type: 'custom',
282
+ [field]: 1000
283
+ }
284
+ },
285
+ field: `items[0].pricing.${field}`,
286
+ message: /decimal string/
287
+ })),
288
+ ...[
289
+ ['zero', 0],
290
+ ['a fraction', 1.5],
291
+ ['a string', '2']
292
+ ].map(([description, quantity]) => ({
293
+ name: `${description} quantity`,
294
+ item: {
295
+ product: 'prod_123',
296
+ quantity
297
+ },
298
+ field: 'items[0].quantity',
299
+ message: /integer of at least 1/
300
+ }))
301
+ ]
302
+
303
+ for (const invalidCase of invalidItemCases) {
304
+ test(`checkout rejects ${invalidCase.name} before calling Bachs`, async () => {
305
+ let fetchCalls = 0
306
+
307
+ fetch.setFetchImplementation(async () => {
308
+ fetchCalls += 1
309
+ })
310
+
311
+ await assert.rejects(
312
+ () =>
313
+ checkout({
314
+ apiKey: 'sk_sandbox_123',
315
+ items: [invalidCase.item]
316
+ }),
317
+ (error) => {
318
+ assert.equal(error.exit, 'invalidRequest')
319
+ assert.equal(error.raw.field, invalidCase.field)
320
+ assert.match(error.raw.message, invalidCase.message)
321
+ return true
322
+ }
323
+ )
324
+
325
+ assert.equal(fetchCalls, 0)
326
+ })
327
+ }
328
+
62
329
  test('adapter exposes checkout.get as the uniform checkout lookup API', async () => {
63
330
  const calls = []
64
331
 
@@ -95,6 +362,4 @@ test('adapter exposes checkout.get as the uniform checkout lookup API', async ()
95
362
  'https://sandbox-api.bachs.io/v1/checkouts/chk_123'
96
363
  )
97
364
  assert.equal(calls[0].options.method, 'GET')
98
-
99
- fetch.resetFetchImplementation()
100
365
  })
@@ -3,9 +3,147 @@ const assert = require('node:assert/strict')
3
3
  const {
4
4
  buildCheckoutSessionPayload,
5
5
  buildPureCheckoutPayload,
6
- buildRefundPayload
6
+ buildRefundPayload,
7
+ buildProductCart,
8
+ normalizePricing
7
9
  } = require('../helpers/payloads')
8
10
 
11
+ test('buildProductCart preserves catalog pricing and optional quantity', () => {
12
+ assert.deepEqual(buildProductCart([{ product: 'prod_catalog' }]), [
13
+ {
14
+ product_id: 'prod_catalog'
15
+ }
16
+ ])
17
+
18
+ assert.deepEqual(
19
+ buildProductCart([{ product: 'prod_quantity', quantity: 2 }]),
20
+ [
21
+ {
22
+ product_id: 'prod_quantity',
23
+ quantity: 2
24
+ }
25
+ ]
26
+ )
27
+ })
28
+
29
+ test('buildProductCart normalizes fixed pricing shorthand and explicit pricing', () => {
30
+ const fixedPricing = {
31
+ product_id: 'prod_fixed',
32
+ pricing: {
33
+ price_type: 'fixed',
34
+ amount: '19.00'
35
+ }
36
+ }
37
+
38
+ assert.deepEqual(
39
+ buildProductCart([{ product: 'prod_fixed', amount: '19.00' }]),
40
+ [fixedPricing]
41
+ )
42
+
43
+ assert.deepEqual(
44
+ buildProductCart([
45
+ {
46
+ product: 'prod_fixed',
47
+ pricing: {
48
+ type: 'fixed',
49
+ amount: '19.00'
50
+ }
51
+ }
52
+ ]),
53
+ [fixedPricing]
54
+ )
55
+ })
56
+
57
+ test('buildProductCart normalizes custom pricing and chosenAmount', () => {
58
+ assert.deepEqual(
59
+ buildProductCart([
60
+ {
61
+ product: 'prod_custom',
62
+ pricing: {
63
+ type: 'custom',
64
+ presetAmount: '10.00',
65
+ minimumAmount: '5.00',
66
+ maximumAmount: '100.00'
67
+ },
68
+ chosenAmount: '12.00'
69
+ }
70
+ ]),
71
+ [
72
+ {
73
+ product_id: 'prod_custom',
74
+ pricing: {
75
+ price_type: 'custom',
76
+ preset_amount: '10.00',
77
+ minimum_amount: '5.00',
78
+ maximum_amount: '100.00'
79
+ },
80
+ amount: '12.00'
81
+ }
82
+ ]
83
+ )
84
+ })
85
+
86
+ test('buildProductCart normalizes free pricing', () => {
87
+ assert.deepEqual(
88
+ buildProductCart([
89
+ {
90
+ product: 'prod_free',
91
+ pricing: {
92
+ type: 'free'
93
+ }
94
+ }
95
+ ]),
96
+ [
97
+ {
98
+ product_id: 'prod_free',
99
+ pricing: {
100
+ price_type: 'free'
101
+ }
102
+ }
103
+ ]
104
+ )
105
+ })
106
+
107
+ test('buildProductCart maps catalog chosenAmount to the flat Bachs amount', () => {
108
+ assert.deepEqual(
109
+ buildProductCart([
110
+ {
111
+ product: 'prod_catalog_custom',
112
+ chosenAmount: '12.00'
113
+ }
114
+ ]),
115
+ [
116
+ {
117
+ product_id: 'prod_catalog_custom',
118
+ amount: '12.00'
119
+ }
120
+ ]
121
+ )
122
+ })
123
+
124
+ test('normalizePricing maps reusable pricing fields to Bachs snake case', () => {
125
+ assert.deepEqual(
126
+ normalizePricing({
127
+ type: 'custom',
128
+ presetAmount: '10.00',
129
+ minimumAmount: '5.00',
130
+ maximumAmount: '100.00',
131
+ currencyOptions: {
132
+ NGN: '15000.00'
133
+ }
134
+ }),
135
+ {
136
+ price_type: 'custom',
137
+ preset_amount: '10.00',
138
+ minimum_amount: '5.00',
139
+ maximum_amount: '100.00',
140
+ currency_options: {
141
+ NGN: '15000.00'
142
+ }
143
+ }
144
+ )
145
+ })
146
+
9
147
  test('buildCheckoutSessionPayload maps product checkout inputs to Bachs snake case', () => {
10
148
  const payload = buildCheckoutSessionPayload(
11
149
  {
@@ -37,7 +175,10 @@ test('buildCheckoutSessionPayload maps product checkout inputs to Bachs snake ca
37
175
  {
38
176
  product_id: 'prod_abc123',
39
177
  quantity: 2,
40
- amount: '50.00'
178
+ pricing: {
179
+ price_type: 'fixed',
180
+ amount: '50.00'
181
+ }
41
182
  }
42
183
  ],
43
184
  billing_currency: 'NGN',
@@ -118,6 +259,34 @@ test('buildPureCheckoutPayload maps amount checkout inputs to Bachs snake case',
118
259
  })
119
260
  })
120
261
 
262
+ test('buildPureCheckoutPayload reuses advanced pricing normalization', () => {
263
+ const payload = buildPureCheckoutPayload({
264
+ pricing: {
265
+ type: 'custom',
266
+ currency: 'USD',
267
+ presetAmount: '10.00',
268
+ minimumAmount: '5.00',
269
+ maximumAmount: '100.00'
270
+ },
271
+ currencyOptions: {
272
+ NGN: '15000.00'
273
+ }
274
+ })
275
+
276
+ assert.deepEqual(payload, {
277
+ pricing: {
278
+ currency: 'USD',
279
+ price_type: 'custom',
280
+ preset_amount: '10.00',
281
+ minimum_amount: '5.00',
282
+ maximum_amount: '100.00',
283
+ currency_options: {
284
+ NGN: '15000.00'
285
+ }
286
+ }
287
+ })
288
+ })
289
+
121
290
  test('buildRefundPayload maps refund inputs to Bachs snake case', () => {
122
291
  const payload = buildRefundPayload({
123
292
  chargeId: 'chr_123',