From b76684535c2855df72c2f6cd8af4d4d2ddcedb03 Mon Sep 17 00:00:00 2001 From: cb-alish Date: Tue, 11 Aug 2026 11:19:34 +0530 Subject: [PATCH 1/4] Send filter array operators as a single form field on POST requests in/not_in/between carry the whole array in one field (updated_at[between]=[a,b]). serialize() did this for GET requests, but POST bodies go straight to encodeParams, which index-encoded the array as [between][0]/[between][1]. Nested filters on export operations were therefore silently dropped by the API. Co-authored-by: Cursor --- src/util.ts | 13 +++++++++++-- test/requestWrapper.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/util.ts b/src/util.ts index 5155113..5978d87 100644 --- a/src/util.ts +++ b/src/util.ts @@ -138,9 +138,12 @@ export function getApiURL( (urlSuffix !== null ? urlSuffix : '') ); } +// Filter operators whose value is the whole array, sent as a single field +// (e.g. updated_at[between]=[1704067200,1717199999]). +const arrayOperators: string[] = ['in', 'not_in', 'between']; + export function serialize(paramObj: any) { let key: string, value: string | Object; - let array_ops: string[] = ['in', 'not_in', 'between']; for (key in paramObj) { value = paramObj[key]; if (typeof value === 'object' && isObject(value)) { @@ -149,7 +152,7 @@ export function serialize(paramObj: any) { for (child_key in value) { key = key + '[' + child_key + ']'; paramObj[key] = (value as any)[child_key]; - if (array_ops.includes(child_key)) { + if (arrayOperators.includes(child_key)) { paramObj[key] = JSON.stringify((value as any)[child_key]); } } @@ -211,6 +214,12 @@ export function encodeParams( ); } serialized.push(encodeURIComponent(key) + '=' + attrVal); + } else if (isArray(value) && arrayOperators.includes(originalKey)) { + serialized.push( + encodeURIComponent(key) + + '=' + + encodeURIComponent(JSON.stringify(value)), + ); } else if ( isArray(value) && !(jsonKeys && jsonKeys[originalKey] === level) diff --git a/test/requestWrapper.test.ts b/test/requestWrapper.test.ts index 023e465..7d21968 100644 --- a/test/requestWrapper.test.ts +++ b/test/requestWrapper.test.ts @@ -88,6 +88,29 @@ describe('RequestWrapper - request body', () => { expect(body).to.not.equal(''); expect(body).to.include('first_name=John'); }); + + it('should send filter array operators as a single field, not indexed entries', async () => { + responseFactory = () => + new Response(JSON.stringify({ export: { id: 'export_123' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + + const chargebee = createChargebee(); + await chargebee.export.subscriptions({ + subscription: { + updated_at: { between: [1704067200, 1717199999] }, + id: { in: ['sub_1', 'sub_2'] }, + }, + }); + + const body = decodeURIComponent(await capturedRequests[0].text()); + expect(body).to.include( + 'subscription[updated_at][between]=[1704067200,1717199999]', + ); + expect(body).to.include('subscription[id][in]=["sub_1","sub_2"]'); + expect(body).to.not.include('[between][0]'); + }); }); }); From d990d9f509c90500be8342b1289cd5e7f813346f Mon Sep 17 00:00:00 2001 From: cb-alish Date: Tue, 11 Aug 2026 11:26:42 +0530 Subject: [PATCH 2/4] Releasing v3.30.1 Co-authored-by: Cursor --- CHANGELOG.md | 5 +++++ VERSION | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src/environment.ts | 2 +- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d986499..30b471b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +### v3.30.1 (2026-08-11) +* * * +### Bug Fixes: +- Filter operators `in`, `not_in` and `between` are now sent as a single form field on POST requests, matching the behaviour on list requests. Filters on export operations, such as `subscription[updated_at][between]`, were index-encoded and therefore ignored by the API. + ### v3.30.0 (2026-07-30) * * * ### New Resources: diff --git a/VERSION b/VERSION index 1cfe511..72bde0a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.30.0 +3.30.1 diff --git a/package-lock.json b/package-lock.json index 20597dc..7ede367 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "chargebee", - "version": "3.30.0", + "version": "3.30.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "chargebee", - "version": "3.30.0", + "version": "3.30.1", "dependencies": { "zod": "^4.3.6" }, diff --git a/package.json b/package.json index 153d29a..638e48d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chargebee", - "version": "3.30.0", + "version": "3.30.1", "description": "A library for integrating with Chargebee.", "scripts": { "prepack": "npm install && npm run build", diff --git a/src/environment.ts b/src/environment.ts index 5082734..901ec60 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -9,7 +9,7 @@ export const Environment = { hostSuffix: '.chargebee.com', apiPath: '/api/v2', timeout: DEFAULT_TIME_OUT, - clientVersion: 'v3.30.0', + clientVersion: 'v3.30.1', port: DEFAULT_PORT, timemachineWaitInMillis: DEFAULT_TIME_MACHINE_WAIT, exportWaitInMillis: DEFAULT_EXPORT_WAIT, From fb452769a8aaae9ec1bc7ff144d5ecb09a7f1579 Mon Sep 17 00:00:00 2001 From: cb-alish Date: Tue, 11 Aug 2026 12:37:44 +0530 Subject: [PATCH 3/4] Add URL form encoder test suite and skip empty filter arrays Covers the encoder cases beyond filters (nested resources, indexed arrays, jsonKeys, escaping) so the filter-operator behaviour is pinned against the rest of the encoder. An empty operator array is now omitted instead of being sent as an empty JSON array. Co-authored-by: Cursor --- src/util.ts | 13 ++- test/util.test.ts | 226 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 test/util.test.ts diff --git a/src/util.ts b/src/util.ts index 5978d87..94144fb 100644 --- a/src/util.ts +++ b/src/util.ts @@ -215,11 +215,14 @@ export function encodeParams( } serialized.push(encodeURIComponent(key) + '=' + attrVal); } else if (isArray(value) && arrayOperators.includes(originalKey)) { - serialized.push( - encodeURIComponent(key) + - '=' + - encodeURIComponent(JSON.stringify(value)), - ); + // An empty filter is not a filter, so leave it out of the request. + if (value.length > 0) { + serialized.push( + encodeURIComponent(key) + + '=' + + encodeURIComponent(JSON.stringify(value)), + ); + } } else if ( isArray(value) && !(jsonKeys && jsonKeys[originalKey] === level) diff --git a/test/util.test.ts b/test/util.test.ts new file mode 100644 index 0000000..a824486 --- /dev/null +++ b/test/util.test.ts @@ -0,0 +1,226 @@ +import { expect } from 'chai'; +import { encodeListParams, encodeParams, serialize } from '../src/util.js'; + +// Bracket notation is easier to read decoded; the escaping itself is asserted +// separately in "value escaping". +function encoded(paramObj: any, jsonKeys?: any): string { + return decodeURIComponent( + encodeParams(paramObj, undefined, undefined, undefined, jsonKeys), + ); +} + +describe('encodeParams - scalars', () => { + it('should encode simple attributes', () => { + expect( + encoded({ first_name: 'John', last_name: 'Doe', locale: 'fr-CA' }), + ).to.equal('first_name=John&last_name=Doe&locale=fr-CA'); + }); + + it('should encode numbers and booleans', () => { + expect( + encoded({ quantity: 1, unit_price: 0, auto_collection: true }), + ).to.equal('quantity=1&unit_price=0&auto_collection=true'); + }); + + it('should escape reserved characters and encode spaces as +', () => { + expect(encodeParams({ first_name: 'John Doe' })).to.equal( + 'first_name=John+Doe', + ); + expect(encodeParams({ email: 'john+cb@test.com' })).to.equal( + 'email=john%2Bcb%40test.com', + ); + expect(encodeParams({ note: 'a&b=c' })).to.equal('note=a%26b%3Dc'); + }); + + it('should keep empty strings but drop null and undefined', () => { + expect(encoded({ a: '', b: null, c: undefined, d: 'x' })).to.equal( + 'a=&d=x', + ); + }); +}); + +describe('encodeParams - nested objects', () => { + it('should encode a sub-resource as bracket notation', () => { + expect( + encoded({ billing_address: { city: 'Walnut', state: 'California' } }), + ).to.equal( + 'billing_address[city]=Walnut&billing_address[state]=California', + ); + }); + + it('should encode arbitrarily deep nesting', () => { + expect(encoded({ ramp: { effective_from: { on: 1704067200 } } })).to.equal( + 'ramp[effective_from][on]=1704067200', + ); + }); + + it('should drop empty objects', () => { + expect(encoded({ meta_data: {}, a: 'x' })).to.equal('a=x'); + }); +}); + +describe('encodeParams - arrays', () => { + it('should index-encode an array of primitives', () => { + expect(encoded({ coupon_ids: ['FIFTYOFF', 'TENOFF'] })).to.equal( + 'coupon_ids[0]=FIFTYOFF&coupon_ids[1]=TENOFF', + ); + }); + + it('should drop empty arrays', () => { + expect(encoded({ coupon_ids: [], a: 'x' })).to.equal('a=x'); + }); + + it('should index an array of sub-resources by field, then position', () => { + expect( + encoded({ + subscription_items: [ + { item_price_id: 'day-pass-USD', unit_price: 100 }, + { item_price_id: 'basic-USD', quantity: 1 }, + ], + }), + ).to.equal( + 'subscription_items[item_price_id][0]=day-pass-USD&subscription_items[unit_price][0]=100&subscription_items[item_price_id][1]=basic-USD&subscription_items[quantity][1]=1', + ); + }); + + it('should index an array nested inside an array of sub-resources', () => { + expect( + encoded({ + item_constraints: [ + { constraint: 'specific', item_price_ids: ['basic', 'pro'] }, + ], + }), + ).to.equal( + 'item_constraints[constraint][0]=specific&item_constraints[item_price_ids][0][0]=basic&item_constraints[item_price_ids][0][1]=pro', + ); + }); +}); + +describe('encodeParams - jsonKeys', () => { + it('should JSON-encode a key registered at the matching level', () => { + expect(encoded({ meta_data: { plan: 'pro' } }, { meta_data: 0 })).to.equal( + 'meta_data={"plan":"pro"}', + ); + }); + + it('should pass an already stringified value through untouched', () => { + expect(encoded({ meta_data: '{"plan":"pro"}' }, { meta_data: 0 })).to.equal( + 'meta_data={"plan":"pro"}', + ); + }); + + it('should JSON-encode a nested key registered at its own level', () => { + expect( + encoded( + { item_constraints: [{ item_price_ids: ['basic'] }] }, + { item_price_ids: 1 }, + ), + ).to.equal('item_constraints[item_price_ids][0]=["basic"]'); + }); + + it('should fall back to bracket notation when the level does not match', () => { + expect(encoded({ meta_data: { plan: 'pro' } }, { meta_data: 1 })).to.equal( + 'meta_data[plan]=pro', + ); + }); + + it('should encode a null value as empty', () => { + expect(encoded({ meta_data: null }, { meta_data: 0 })).to.equal( + 'meta_data=', + ); + }); +}); + +describe('encodeParams - filter array operators', () => { + it('should send between as a single field for a nested filter', () => { + expect( + encoded({ + export_type: 'import_friendly_data', + ramp: { effective_from: { between: [1704067200, 1717199999] } }, + }), + ).to.equal( + 'export_type=import_friendly_data&ramp[effective_from][between]=[1704067200,1717199999]', + ); + }); + + it('should send between as a single field for a top-level filter', () => { + expect( + encoded({ updated_at: { between: [1704067200, 1717199999] } }), + ).to.equal('updated_at[between]=[1704067200,1717199999]'); + }); + + it('should send in and not_in as single fields', () => { + expect( + encoded({ + subscription: { + id: { in: ['sub_1', 'sub_2'] }, + status: { not_in: ['cancelled'] }, + }, + }), + ).to.equal( + 'subscription[id][in]=["sub_1","sub_2"]&subscription[status][not_in]=["cancelled"]', + ); + }); + + it('should leave other operators on the same filter alone', () => { + expect( + encoded({ + subscription: { + updated_at: { after: 1704067200, between: [1704067200, 1717199999] }, + }, + }), + ).to.equal( + 'subscription[updated_at][after]=1704067200&subscription[updated_at][between]=[1704067200,1717199999]', + ); + }); + + it('should pass an already stringified operator value through untouched', () => { + expect( + encoded({ updated_at: { between: '[1704067200,1717199999]' } }), + ).to.equal('updated_at[between]=[1704067200,1717199999]'); + }); + + it('should not touch a scalar under an operator key', () => { + expect(encoded({ subscription: { id: { in: 'sub_1' } } })).to.equal( + 'subscription[id][in]=sub_1', + ); + }); + + it('should omit an operator whose array is empty', () => { + expect(encoded({ updated_at: { between: [] }, limit: 5 })).to.equal( + 'limit=5', + ); + }); +}); + +describe('serialize - GET query string', () => { + it('should produce the same filter encoding as the request body', () => { + const params = { + ramp: { effective_from: { between: [1704067200, 1717199999] } }, + }; + const query = decodeURIComponent(encodeParams(serialize(params))); + + expect(query).to.equal( + 'ramp[effective_from][between]=[1704067200,1717199999]', + ); + }); + + it('should flatten sub-resources into bracket notation', () => { + expect(serialize({ billing_address: { city: 'Walnut' } })).to.deep.equal({ + 'billing_address[city]': 'Walnut', + }); + }); +}); + +describe('encodeListParams', () => { + it('should JSON-encode array values and leave scalars alone', () => { + expect( + decodeURIComponent( + encodeListParams({ + 'updated_at[between]': [1704067200, 1717199999], + limit: 5, + }), + ), + ).to.equal('updated_at[between]=[1704067200,1717199999]&limit=5'); + }); +}); From ac7237ca7165e544c80be9477232373df3649a0e Mon Sep 17 00:00:00 2001 From: cb-alish Date: Tue, 11 Aug 2026 12:45:08 +0530 Subject: [PATCH 4/4] Cover clearing a json key with an empty object meta_data and other json keys carry a JSON document, so an empty object is a meaningful value that clears it. Co-authored-by: Cursor --- test/util.test.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/test/util.test.ts b/test/util.test.ts index a824486..329f0de 100644 --- a/test/util.test.ts +++ b/test/util.test.ts @@ -54,8 +54,8 @@ describe('encodeParams - nested objects', () => { ); }); - it('should drop empty objects', () => { - expect(encoded({ meta_data: {}, a: 'x' })).to.equal('a=x'); + it('should contribute nothing for a sub-resource with no fields', () => { + expect(encoded({ card: {}, a: 'x' })).to.equal('a=x'); }); }); @@ -109,6 +109,23 @@ describe('encodeParams - jsonKeys', () => { ); }); + // meta_data is a json key on every endpoint that accepts it, so sending an + // empty object is how a caller wipes the stored document. + it('should send an empty object so the field can be cleared', () => { + expect(encoded({ meta_data: {}, id: 'cust_1' }, { meta_data: 0 })).to.equal( + 'meta_data={}&id=cust_1', + ); + }); + + it('should send an empty object for a nested json key too', () => { + expect( + encoded( + { subscription_items: [{ billing_address: {} }] }, + { billing_address: 1 }, + ), + ).to.equal('subscription_items[billing_address][0]={}'); + }); + it('should JSON-encode a nested key registered at its own level', () => { expect( encoded(